This is a large refactor that aims to improve performance by offloading RelayPool computations into a separate actor outside the main thread. This should reduce congestion on the main thread and thus improve UI performance. Also, the internal subscription callback mechanism was changed to use AsyncStreams to prevent race conditions newly found in that area of the code. Changelog-Fixed: Added performance improvements to timeline scrolling Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
978 lines
38 KiB
Swift
978 lines
38 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: { 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<Pubkey>) async -> 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 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)
|
|
}
|
|
|