// // 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 = [] @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] = [] 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: { await 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() async { let new_post = await 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.")) { Task { await 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 } Task{ await 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, damusState: damus_state) 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 if isSupportedVideo(url: media[index].uploadedURL) { VideoPlayer(player: configurePlayer(with: media[index].localURL)) .aspectRatio(contentMode: .fit) .frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, alignment: .topLeading) .cornerRadius(10) .contextMenu { contextMenuContent(for: media[index]) } .overlay( Button(action: { media.remove(at: index) }) { closeImageView } .padding([.top, .leading], 8), alignment: .topLeading ) .overlay( Image(systemName: "video") .foregroundColor(.white) .padding(10) .background(Color.black.opacity(0.5)) .clipShape(Circle()) .shadow(radius: 5) .opacity(0.6), alignment: .bottomLeading ) } else { KFAnimatedImage(media[index].uploadedURL) .imageContext(.note, disable_animation: false) .configure { view in view.framePreloadCount = 3 } .aspectRatio(contentMode: .fit) .frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, alignment: .topLeading) .cornerRadius(10) .contextMenu { contextMenuContent(for: media[index]) } .overlay( Button(action: { media.remove(at: index) }) { closeImageView } .padding([.top, .leading], 8), alignment: .topLeading ) } } if let mediaUP = mediaUnderProgress, let progress = imageUploadModel.progress { ZStack { // Media under upload-progress Image(uiImage: getImage(media: mediaUP)) .resizable() .aspectRatio(contentMode: .fit) .frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, alignment: .topLeading) .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 } private var closeImageView: some View { Image("close-circle") .foregroundColor(.white) .background(Color.black.opacity(0.5)) .clipShape(Circle()) .shadow(radius: 5) } } 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, relayURL: RelayURL?) -> [[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(), relayURL?.absoluteString ?? "", "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(), relayURL?.absoluteString ?? "", "reply"] ] return tags } func build_post(state: DamusState, action: PostAction, draft: DraftArtifacts) async -> NostrPost { return await 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) async -> NostrPost { // don't add duplicate pubkeys but retain order var pkset = Set() // we only want pubkeys really let pks = references.reduce(into: Array()) { 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 await 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]) async -> NostrPost { let post = NSMutableAttributedString(attributedString: post) post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in let linkValue = attributes[.link] let link = (linkValue as? String) ?? (linkValue as? URL)?.absoluteString if let link { 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, relayURL: await state.nostrNetwork.relaysForEvent(event: replying_to).first) case .quoting(let ev): let relay_urls = await state.nostrNetwork.relaysForEvent(event: ev) let nevent = Bech32Object.encode(.nevent(NEvent(event: ev, relays: relay_urls.prefix(4).map { $0 }))) content.append("\n\nnostr:\(nevent)") if let first_relay = relay_urls.first?.absoluteString { tags.append(["q", ev.id.hex(), first_relay, ev.pubkey.hex()]); tags.append(["p", ev.pubkey.hex(), first_relay]) } else { tags.append(["q", ev.id.hex(), "", ev.pubkey.hex()]); tags.append(["p", 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) }