Files
damus/damus/Views/PostView.swift
Swift Coder e9c1671d06 Display Circular Indicator on top of media undergoing upload process
Removed existing progress view bar at the top of post view
Added separate stack in PVImageCarouselView for media undergoing the upload process
Changelog-Added: Display uploading indicator in post view
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2025-05-26 17:21:06 -07:00

958 lines
37 KiB
Swift

//
// Post.swift
// damus
//
// Created by William Casarin on 2022-04-03.
//
import SwiftUI
import AVKit
import Kingfisher
enum NostrPostResult {
case post(NostrPost)
case cancel
}
let POST_PLACEHOLDER = NSLocalizedString("Type your note here...", comment: "Text box prompt to ask user to type their note.")
let GHOST_CARET_VIEW_ID = "GhostCaret"
let DEBUG_SHOW_GHOST_CARET_VIEW: Bool = false
class TagModel: ObservableObject {
var diff = 0
}
enum PostTarget {
case none
case user(Pubkey)
}
enum PostAction {
case replying_to(NostrEvent)
case quoting(NostrEvent)
case posting(PostTarget)
case highlighting(HighlightContentDraft)
case sharing(ShareContent)
var ev: NostrEvent? {
switch self {
case .replying_to(let ev):
return ev
case .quoting(let ev):
return ev
case .posting:
return nil
case .highlighting:
return nil
case .sharing(_):
return nil
}
}
}
struct PostView: View {
@State var post: NSMutableAttributedString = NSMutableAttributedString()
@State var uploadedMedias: [UploadedMedia] = []
@State var references: [RefId] = []
/// Pubkeys that should be filtered out from the references
///
/// For example, when replying to an event, the user can select which pubkey mentions they want to keep, and which ones to remove.
@State var filtered_pubkeys: Set<Pubkey> = []
@FocusState var focus: Bool
@State var attach_media: Bool = false
@State var attach_camera: Bool = false
@State var error: String? = nil
@State var image_upload_confirm: Bool = false
@State var imagePastedFromPasteboard: PreUploadedMedia? = nil
@State var imageUploadConfirmPasteboard: Bool = false
@State var imageUploadConfirmDamusShare: Bool = false
@State var focusWordAttributes: (String?, NSRange?) = (nil, nil)
@State var newCursorIndex: Int?
@State var textHeight: CGFloat? = nil
/// Manages the auto-save logic for drafts.
///
/// ## Implementation notes
///
/// - This intentionally does _not_ use `@ObservedObject` or `@StateObject` because observing changes causes unwanted automatic scrolling to the text cursor on each save state update.
var autoSaveModel: AutoSaveIndicatorView.AutoSaveViewModel
@State var preUploadedMedia: [PreUploadedMedia] = []
@State var mediaUploadUnderProgress: MediaUpload? = nil
@StateObject var image_upload: ImageUploadModel = ImageUploadModel()
@StateObject var tagModel: TagModel = TagModel()
@State private var current_placeholder_index = 0
@State private var uploadTasks: [Task<Void, Never>] = []
let action: PostAction
let damus_state: DamusState
let prompt_view: (() -> AnyView)?
let placeholder_messages: [String]
let initial_text_suffix: String?
init(
action: PostAction,
damus_state: DamusState,
prompt_view: (() -> AnyView)? = nil,
placeholder_messages: [String]? = nil,
initial_text_suffix: String? = nil
) {
self.action = action
self.damus_state = damus_state
self.prompt_view = prompt_view
self.placeholder_messages = placeholder_messages ?? [POST_PLACEHOLDER]
self.initial_text_suffix = initial_text_suffix
self.autoSaveModel = AutoSaveIndicatorView.AutoSaveViewModel(save: { damus_state.drafts.save(damus_state: damus_state) })
}
@Environment(\.dismiss) var dismiss
func cancel() {
notify(.post(.cancel))
cancelUploadTasks()
dismiss()
}
func cancelUploadTasks() {
uploadTasks.forEach { $0.cancel() }
uploadTasks.removeAll()
}
func send_post() {
let new_post = build_post(state: self.damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: self.references, filtered_pubkeys: filtered_pubkeys)
notify(.post(.post(new_post)))
clear_draft()
dismiss()
}
var is_post_empty: Bool {
return post.string.allSatisfy { $0.isWhitespace } && uploadedMedias.isEmpty
}
var uploading_disabled: Bool {
return image_upload.progress != nil
}
var posting_disabled: Bool {
switch action {
case .highlighting(_):
return false
default:
return is_post_empty || uploading_disabled
}
}
// Returns a valid height for the text box, even when textHeight is not a number
func get_valid_text_height() -> CGFloat {
if let textHeight, textHeight.isFinite, textHeight > 0 {
return textHeight
}
else {
return 10
}
}
var ImageButton: some View {
Button(action: {
preUploadedMedia.removeAll()
attach_media = true
}, label: {
Image("images")
.padding(6)
})
}
var CameraButton: some View {
Button(action: {
attach_camera = true
}, label: {
Image("camera")
.padding(6)
})
}
var AttachmentBar: some View {
HStack(alignment: .center, spacing: 15) {
ImageButton
CameraButton
Spacer()
AutoSaveIndicatorView(saveViewModel: self.autoSaveModel)
}
.disabled(uploading_disabled)
}
var PostButton: some View {
Button(NSLocalizedString("Post", comment: "Button to post a note.")) {
self.send_post()
}
.disabled(posting_disabled)
.opacity(posting_disabled ? 0.5 : 1.0)
.bold()
.buttonStyle(GradientButtonStyle(padding: 10))
}
func isEmpty() -> Bool {
return self.uploadedMedias.count == 0 &&
self.post.mutableString.trimmingCharacters(in: .whitespacesAndNewlines) ==
initialString().mutableString.trimmingCharacters(in: .whitespacesAndNewlines)
}
func initialString() -> NSMutableAttributedString {
guard case .posting(let target) = action,
case .user(let pubkey) = target,
damus_state.pubkey != pubkey else {
return .init(string: "")
}
let profile_txn = damus_state.profiles.lookup(id: pubkey)
let profile = profile_txn?.unsafeUnownedValue
return user_tag_attr_string(profile: profile, pubkey: pubkey)
}
func clear_draft() {
switch action {
case .replying_to(let replying_to):
damus_state.drafts.replies.removeValue(forKey: replying_to.id)
case .quoting(let quoting):
damus_state.drafts.quotes.removeValue(forKey: quoting.id)
case .posting:
damus_state.drafts.post = nil
case .highlighting(let draft):
damus_state.drafts.highlights.removeValue(forKey: draft)
case .sharing(_):
damus_state.drafts.post = nil
}
damus_state.drafts.save(damus_state: damus_state)
}
func load_draft() -> Bool {
guard let draft = load_draft_for_post(drafts: self.damus_state.drafts, action: self.action) else {
self.post = NSMutableAttributedString("")
self.uploadedMedias = []
self.autoSaveModel.markNothingToSave() // We should not save empty drafts.
return false
}
self.uploadedMedias = draft.media
self.post = draft.content
self.autoSaveModel.markSaved() // The draft we just loaded is saved to memory. Mark it as such.
return true
}
/// Use this to signal that the post contents have changed. This will do two things:
///
/// 1. Save the new contents into our in-memory drafts
/// 2. Signal that we need to save drafts persistently, which will happen after a certain wait period
func post_changed(post: NSMutableAttributedString, media: [UploadedMedia]) {
if let draft = load_draft_for_post(drafts: damus_state.drafts, action: action) {
draft.content = post
draft.media = uploadedMedias
draft.references = references
draft.filtered_pubkeys = filtered_pubkeys
} else {
let artifacts = DraftArtifacts(content: post, media: uploadedMedias, references: references, id: UUID().uuidString)
set_draft_for_post(drafts: damus_state.drafts, action: action, artifacts: artifacts)
}
self.autoSaveModel.needsSaving()
}
var TextEntry: some View {
ZStack(alignment: .topLeading) {
TextViewWrapper(
attributedText: $post,
textHeight: $textHeight,
initialTextSuffix: initial_text_suffix,
imagePastedFromPasteboard: $imagePastedFromPasteboard,
imageUploadConfirmPasteboard: $imageUploadConfirmPasteboard,
cursorIndex: newCursorIndex,
getFocusWordForMention: { word, range in
focusWordAttributes = (word, range)
self.newCursorIndex = nil
},
updateCursorPosition: { newCursorIndex in
self.newCursorIndex = newCursorIndex
}
)
.environmentObject(tagModel)
.focused($focus)
.textInputAutocapitalization(.sentences)
.onChange(of: post) { p in
post_changed(post: p, media: uploadedMedias)
}
// Set a height based on the text content height, if it is available and valid
.frame(height: get_valid_text_height())
if post.string.isEmpty {
Text(self.placeholder_messages[self.current_placeholder_index])
.padding(.top, 8)
.padding(.leading, 4)
.foregroundColor(Color(uiColor: .placeholderText))
.allowsHitTesting(false)
}
}
.onAppear {
// Schedule a timer to switch messages every 3 seconds
Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { timer in
withAnimation {
self.current_placeholder_index = (self.current_placeholder_index + 1) % self.placeholder_messages.count
}
}
}
}
var TopBar: some View {
VStack {
HStack(spacing: 5.0) {
Button(action: {
self.cancel()
}, label: {
Text("Cancel", comment: "Button to cancel out of posting a note.")
.padding(10)
})
.buttonStyle(NeutralButtonStyle())
.accessibilityIdentifier(AppAccessibilityIdentifiers.post_composer_cancel_button.rawValue)
if let error {
Text(error)
.foregroundColor(.red)
}
Spacer()
PostButton
}
Divider()
.foregroundColor(DamusColors.neutral3)
.padding(.top, 5)
}
.frame(height: 30)
.padding()
.padding(.top, 15)
}
@discardableResult
func handle_upload(media: MediaUpload) async -> Bool {
mediaUploadUnderProgress = media
let uploader = damus_state.settings.default_media_uploader
let img = getImage(media: media)
print("img size w:\(img.size.width) h:\(img.size.height)")
async let blurhash = calculate_blurhash(img: img)
let res = await image_upload.start(media: media, uploader: uploader, mediaType: .normal, keypair: damus_state.keypair)
mediaUploadUnderProgress = nil
switch res {
case .success(let url):
guard let url = URL(string: url) else {
self.error = "Error uploading image :("
return false
}
let blurhash = await blurhash
let meta = blurhash.map { bh in calculate_image_metadata(url: url, img: img, blurhash: bh) }
let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, metadata: meta)
uploadedMedias.append(uploadedMedia)
return true
case .failed(let error):
if let error {
self.error = error.localizedDescription
} else {
self.error = "Error uploading image :("
}
return false
}
}
var multiply_factor: CGFloat {
if case .quoting = action {
return 0.4
} else if !uploadedMedias.isEmpty {
return 0.2
} else {
return 1.0
}
}
func Editor(deviceSize: GeometryProxy) -> some View {
HStack(alignment: .top, spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
ProfilePicView(pubkey: damus_state.pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
VStack(alignment: .leading) {
if let prompt_view {
prompt_view()
}
TextEntry
}
}
.id("post")
PVImageCarouselView(media: $uploadedMedias,
mediaUnderProgress: $mediaUploadUnderProgress,
imageUploadModel: image_upload,
deviceWidth: deviceSize.size.width)
.onChange(of: uploadedMedias) { media in
post_changed(post: post, media: media)
}
if case .quoting(let ev) = action {
BuilderEventView(damus: damus_state, event: ev)
}
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)
}
}
func fill_target_content(target: PostTarget) {
self.post = initialString()
self.tagModel.diff = post.string.count
}
var pubkeys: [Pubkey] {
self.references.reduce(into: [Pubkey]()) { pks, ref in
guard case .pubkey(let pk) = ref else {
return
}
pks.append(pk)
}
}
var body: some View {
GeometryReader { (deviceSize: GeometryProxy) in
VStack(alignment: .leading, spacing: 0) {
let searching = get_searching_string(focusWordAttributes.0)
let searchingHashTag = get_searching_hashTag(focusWordAttributes.0)
TopBar
ScrollViewReader { scroller in
ScrollView {
VStack(alignment: .leading) {
if case .replying_to(let replying_to) = self.action {
ReplyView(replying_to: replying_to, damus: damus_state, original_pubkeys: pubkeys, filtered_pubkeys: $filtered_pubkeys)
}
Editor(deviceSize: deviceSize)
.padding(.top, 5)
}
}
.frame(maxHeight: searching == nil && searchingHashTag == nil ? deviceSize.size.height : 70)
.onAppear {
scroll_to_event(scroller: scroller, id: "post", delay: 1.0, animate: true, anchor: .top)
}
}
// This if-block observes @ for tagging
if let searching {
UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, post: $post)
.frame(maxHeight: .infinity)
.environmentObject(tagModel)
// This else observes '#' for hash-tag suggestions and creates SuggestedHashtagsView
} else if let searchingHashTag {
SuggestedHashtagsView(damus_state: damus_state,
events: SearchHomeModel(damus_state: damus_state).events,
isFromPostView: true,
queryHashTag: searchingHashTag,
focusWordAttributes: $focusWordAttributes,
newCursorIndex: $newCursorIndex,
post: $post)
.environmentObject(tagModel)
} else {
Divider()
VStack(alignment: .leading) {
AttachmentBar
.padding(.vertical, 5)
.padding(.horizontal)
}
}
}
.background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all))
.sheet(isPresented: $attach_media) {
MediaPicker(mediaPickerEntry: .postView, onMediaSelected: { image_upload_confirm = true }) { media in
self.preUploadedMedia.append(media)
}
.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) {
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
// initiate asynchronous uploading Task for multiple-images
let task = Task {
for media in preUploadedMedia {
if let mediaToUpload = generateMediaUpload(media) {
await self.handle_upload(media: mediaToUpload)
}
}
}
uploadTasks.append(task)
self.attach_media = false
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {
preUploadedMedia.removeAll()
}
}
}
.sheet(isPresented: $attach_camera) {
CameraController(uploader: damus_state.settings.default_media_uploader, mode: .save_to_library(when_done: {
self.attach_camera = false
self.attach_media = true
}))
}
// This alert seeks confirmation about Image-upload when user taps Paste option
.alert(NSLocalizedString("Are you sure you want to upload this media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $imageUploadConfirmPasteboard) {
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
if let image = imagePastedFromPasteboard,
let mediaToUpload = generateMediaUpload(image) {
let task = Task {
_ = await self.handle_upload(media: mediaToUpload)
}
uploadTasks.append(task)
}
}
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) {
let task = Task {
for media in preUploadedMedia {
if let mediaToUpload = generateMediaUpload(media) {
await self.handle_upload(media: mediaToUpload)
}
}
}
uploadTasks.append(task)
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
}
.onAppear() {
let loaded_draft = load_draft()
switch action {
case .replying_to(let replying_to):
references = gather_reply_ids(our_pubkey: damus_state.pubkey, from: replying_to)
case .quoting(let quoting):
references = gather_quote_ids(our_pubkey: damus_state.pubkey, from: quoting)
case .posting(let target):
guard !loaded_draft else { break }
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) {
self.focus = true
}
}
.onDisappear {
if isEmpty() {
clear_draft()
}
preUploadedMedia.removeAll()
}
}
}
}
func get_searching_string(_ word: String?) -> String? {
guard let word = word else {
return nil
}
guard word.count >= 2 else {
return nil
}
guard let firstCharacter = word.first,
firstCharacter == "@" else {
return nil
}
// don't include @npub... strings
guard word.count != 64 else {
return nil
}
return String(word.dropFirst())
}
fileprivate func get_searching_hashTag(_ word: String?) -> String? {
guard let word,
word.count >= 2,
let first_char = word.first,
first_char == "#" else {
return nil
}
return String(word.dropFirst())
}
struct PostView_Previews: PreviewProvider {
static var previews: some View {
PostView(action: .posting(.none), damus_state: test_damus_state)
}
}
struct PVImageCarouselView: View {
@Binding var media: [UploadedMedia]
@Binding var mediaUnderProgress: MediaUpload?
@ObservedObject var imageUploadModel: ImageUploadModel
let deviceWidth: CGFloat
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(media.indices, id: \.self) { index in
ZStack(alignment: .topLeading) {
if isSupportedVideo(url: media[index].uploadedURL) {
VideoPlayer(player: configurePlayer(with: media[index].localURL))
.frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, height: media.count == 1 ? 400 : 250)
.cornerRadius(10)
.padding()
.contextMenu { contextMenuContent(for: media[index]) }
} else {
KFAnimatedImage(media[index].uploadedURL)
.imageContext(.note, disable_animation: false)
.configure { view in
view.framePreloadCount = 3
}
.frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, height: media.count == 1 ? 400 : 250)
.cornerRadius(10)
.padding()
.contextMenu { contextMenuContent(for: media[index]) }
}
VStack { // Set spacing to 0 to remove the gap between items
Image("close-circle")
.foregroundColor(.white)
.padding(20)
.shadow(radius: 5)
.onTapGesture {
media.remove(at: index) // Direct removal using index
}
if isSupportedVideo(url: media[index].uploadedURL) {
Spacer()
Image(systemName: "video")
.foregroundColor(.white)
.padding(10)
.shadow(radius: 5)
.opacity(0.6)
}
}
.padding(.bottom, 35)
}
}
if let mediaUP = mediaUnderProgress, let progress = imageUploadModel.progress {
ZStack {
// Media under upload-progress
Image(uiImage: getImage(media: mediaUP))
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: media.count == 0 ? deviceWidth * 0.8 : 250, height: media.count == 0 ? 400 : 250)
.cornerRadius(10)
.opacity(0.3)
.padding()
// Circle showing progress on top of media
Circle()
.trim(from: 0, to: CGFloat(progress))
.stroke(Color.damusPurple, lineWidth: 5.0)
.rotationEffect(.degrees(-90))
.frame(width: 30, height: 30)
.padding()
}
}
}
.padding()
}
}
// Helper Function for Context Menu
@ViewBuilder
private func contextMenuContent(for mediaItem: UploadedMedia) -> some View {
Button(action: {
UIPasteboard.general.string = mediaItem.uploadedURL.absoluteString
}) {
Label(
NSLocalizedString("Copy URL", comment: "Copy URL of the selected uploaded media asset."),
systemImage: "doc.on.doc"
)
}
}
private func configurePlayer(with url: URL) -> AVPlayer {
let player = AVPlayer(url: url)
player.allowsExternalPlayback = false
player.usesExternalPlaybackWhileExternalScreenIsActive = false
return player
}
}
fileprivate func getImage(media: MediaUpload) -> UIImage {
var uiimage: UIImage = UIImage()
if media.is_image {
// fetch the image data
if let data = try? Data(contentsOf: media.localURL) {
uiimage = UIImage(data: data) ?? UIImage()
}
} else {
let asset = AVURLAsset(url: media.localURL)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
let time = CMTimeMake(value: 1, timescale: 60) // get the thumbnail image at the 1st second
do {
let cgImage = try generator.copyCGImage(at: time, actualTime: nil)
uiimage = UIImage(cgImage: cgImage)
} catch {
print("No thumbnail: \(error)")
}
// create a play icon on the top to differentiate if media upload is image or a video, gif is an image
let playIcon = UIImage(systemName: "play.fill")?.withTintColor(.white, renderingMode: .alwaysOriginal)
let size = uiimage.size
let scale = UIScreen.main.scale
UIGraphicsBeginImageContextWithOptions(size, false, scale)
uiimage.draw(at: .zero)
let playIconSize = CGSize(width: 60, height: 60)
let playIconOrigin = CGPoint(x: (size.width - playIconSize.width) / 2, y: (size.height - playIconSize.height) / 2)
playIcon?.draw(in: CGRect(origin: playIconOrigin, size: playIconSize))
let newImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
uiimage = newImage ?? UIImage()
}
return uiimage
}
struct UploadedMedia: Equatable {
let localURL: URL
let uploadedURL: URL
let metadata: ImageMetadata?
}
func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArtifacts) {
switch action {
case .replying_to(let ev):
drafts.replies[ev.id] = artifacts
case .quoting(let ev):
drafts.quotes[ev.id] = artifacts
case .posting:
drafts.post = artifacts
case .highlighting(let draft):
drafts.highlights[draft] = artifacts
case .sharing(_):
drafts.post = artifacts
}
}
func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts? {
switch action {
case .replying_to(let ev):
return drafts.replies[ev.id]
case .quoting(let ev):
return drafts.quotes[ev.id]
case .posting:
return drafts.post
case .highlighting(let highlight):
if let exact_match = drafts.highlights[highlight] {
return exact_match // Always prefer to return the draft for that exact same highlight
}
// If there are no exact matches to the highlight, try to load a draft for the same highlight source
// 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.
let other_matches = drafts.highlights
.filter { $0.key.source == highlight.source }
// 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.
return other_matches.first?.value
case .sharing(_):
return drafts.post
}
}
private func isAlphanumeric(_ char: Character) -> Bool {
return char.isLetter || char.isNumber
}
func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair) -> [[String]] {
guard let nip10 = replying_to.thread_reply() else {
// we're replying to a post that isn't in a thread,
// just add a single reply-to-root tag
return [["e", replying_to.id.hex(), "", "root"]]
}
// otherwise use the root tag from the parent's nip10 reply and include the note
// that we are replying to's note id.
let tags = [
["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"],
["e", replying_to.id.hex(), "", "reply"]
]
return tags
}
func build_post(state: DamusState, action: PostAction, draft: DraftArtifacts) -> NostrPost {
return build_post(
state: state,
post: draft.content,
action: action,
uploadedMedias: draft.media,
references: draft.references,
filtered_pubkeys: draft.filtered_pubkeys
)
}
func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId], filtered_pubkeys: Set<Pubkey>) -> NostrPost {
// don't add duplicate pubkeys but retain order
var pkset = Set<Pubkey>()
// we only want pubkeys really
let pks = references.reduce(into: Array<Pubkey>()) { acc, ref in
guard case .pubkey(let pk) = ref else {
return
}
if pkset.contains(pk) || filtered_pubkeys.contains(pk) {
return
}
pkset.insert(pk)
acc.append(pk)
}
return build_post(state: state, post: post, action: action, uploadedMedias: uploadedMedias, pubkeys: pks)
}
/// This builds a Nostr post from draft data from `PostView` or other draft-related classes
///
/// ## Implementation notes
///
/// - This function _likely_ causes no side-effects, and _should not_ cause side-effects to any of the inputs.
///
/// - Parameters:
/// - state: The damus state, needed to fetch more Nostr data to form this event
/// - post: The text content from `PostView`.
/// - action: The intended action of the post (highlighting? replying?)
/// - uploadedMedias: The medias attached to this post
/// - pubkeys: The referenced pubkeys
/// - Returns: A NostrPost, which can then be signed into an event.
func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost {
let post = NSMutableAttributedString(attributedString: post)
post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in
if let link = attributes[.link] as? String {
let nextCharIndex = range.upperBound
if nextCharIndex < post.length,
let nextChar = post.attributedSubstring(from: NSRange(location: nextCharIndex, length: 1)).string.first,
isAlphanumeric(nextChar) {
post.insert(NSAttributedString(string: " "), at: nextCharIndex)
}
let normalized_link: String
if link.hasPrefix("damus:nostr:") {
// Replace damus:nostr: URI prefix with nostr: since the former is for internal navigation and not meant to be posted.
normalized_link = String(link.dropFirst(6))
} else {
normalized_link = link
}
// Add zero-width space in case text preceding the mention is not a whitespace.
// In the case where the character preceding the mention is a whitespace, the added zero-width space will be stripped out.
post.replaceCharacters(in: range, with: "\(normalized_link)")
}
}
var content = post.string
.trimmingCharacters(in: .whitespacesAndNewlines)
let imagesString = uploadedMedias.map { $0.uploadedURL.absoluteString }.joined(separator: "\n")
if !imagesString.isEmpty {
content.append("\n\n" + imagesString)
}
var tags: [[String]] = []
switch action {
case .replying_to(let replying_to):
// start off with the reply tags
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
case .quoting(let ev):
content.append("\n\nnostr:" + bech32_note_id(ev.id))
tags.append(["q", ev.id.hex()]);
if let quoted_ev = state.events.lookup(ev.id) {
tags.append(["p", quoted_ev.pubkey.hex()])
}
case .posting, .highlighting, .sharing:
break
}
// append additional tags
tags += uploadedMedias.compactMap { $0.metadata?.to_tag() }
switch action {
case .highlighting(let draft):
tags.append(contentsOf: draft.source.tags())
if !(content.isEmpty || content.allSatisfy { $0.isWhitespace }) {
tags.append(["comment", content])
}
tags += pubkeys.map { pk in
["p", pk.hex(), "mention"]
}
return NostrPost(content: draft.selected_text, kind: .highlight, tags: tags)
default:
tags += pubkeys.map { pk in
["p", pk.hex()]
}
}
return NostrPost(content: content.trimmingCharacters(in: .whitespacesAndNewlines), kind: .text, tags: tags)
}
func isSupportedVideo(url: URL?) -> Bool {
guard let url = url else { return false }
let fileExtension = url.pathExtension.lowercased()
let supportedUTIs = AVURLAsset.audiovisualTypes().map { $0.rawValue }
return supportedUTIs.contains { utiString in
if let utType = UTType(utiString), let fileUTType = UTType(filenameExtension: fileExtension) {
return fileUTType.conforms(to: utType)
}
return false
}
}
func isSupportedImage(url: URL) -> Bool {
let fileExtension = url.pathExtension.lowercased()
// It would be better to pull this programmatically from Apple's APIs, but there seems to be no such call
let supportedTypes = ["jpg", "png", "gif"]
return supportedTypes.contains(fileExtension)
}