Upgrade the ImagePicker to use PHPickerViewController instead of UIImagePickerController for the photo library since the PHPickerViewController allows for more options for how the user shares their media, such as whether location data should be included. Create a CameraController since PHPickerViewController is only used for the photo library. After capturing media with the camera, it will be saved to the user's photo library and the photo library view will appear for the user to select the media they captured. The user can then toggle whether they would like apple to strip their media of location data before handing it off to Damus. Lightning-address: kernelkind@getalby.com Signed-off-by: kernelkind <kernelkind@gmail.com> Signed-off-by: William Casarin <jb55@jb55.com>
654 lines
22 KiB
Swift
654 lines
22 KiB
Swift
//
|
|
// Post.swift
|
|
// damus
|
|
//
|
|
// Created by William Casarin on 2022-04-03.
|
|
//
|
|
|
|
import SwiftUI
|
|
import AVFoundation
|
|
|
|
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)
|
|
|
|
var ev: NostrEvent? {
|
|
switch self {
|
|
case .replying_to(let ev):
|
|
return ev
|
|
case .quoting(let ev):
|
|
return ev
|
|
case .posting:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PostView: View {
|
|
@State var post: NSMutableAttributedString = NSMutableAttributedString()
|
|
@FocusState var focus: Bool
|
|
@State var attach_media: Bool = false
|
|
@State var attach_camera: Bool = false
|
|
@State var error: String? = nil
|
|
@State var uploadedMedias: [UploadedMedia] = []
|
|
@State var image_upload_confirm: Bool = false
|
|
@State var references: [RefId] = []
|
|
@State var filtered_pubkeys: Set<Pubkey> = []
|
|
@State var focusWordAttributes: (String?, NSRange?) = (nil, nil)
|
|
@State var newCursorIndex: Int?
|
|
@State var textHeight: CGFloat? = nil
|
|
|
|
@State var mediaToUpload: MediaUpload? = nil
|
|
|
|
@StateObject var image_upload: ImageUploadModel = ImageUploadModel()
|
|
@StateObject var tagModel: TagModel = TagModel()
|
|
|
|
@State private var current_placeholder_index = 0
|
|
|
|
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
|
|
}
|
|
|
|
@Environment(\.dismiss) var dismiss
|
|
|
|
func cancel() {
|
|
notify(.post(.cancel))
|
|
dismiss()
|
|
}
|
|
|
|
func send_post() {
|
|
let refs = references.filter { ref in
|
|
if case .pubkey(let pk) = ref, filtered_pubkeys.contains(pk) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
let new_post = build_post(state: damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: refs)
|
|
|
|
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 {
|
|
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: {
|
|
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
|
|
}
|
|
.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)
|
|
case .quoting(let quoting):
|
|
damus_state.drafts.quotes.removeValue(forKey: quoting)
|
|
case .posting:
|
|
damus_state.drafts.post = nil
|
|
}
|
|
|
|
}
|
|
|
|
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 = []
|
|
|
|
return false
|
|
}
|
|
|
|
self.uploadedMedias = draft.media
|
|
self.post = draft.content
|
|
return true
|
|
}
|
|
|
|
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 = media
|
|
} else {
|
|
let artifacts = DraftArtifacts(content: post, media: media)
|
|
set_draft_for_post(drafts: damus_state.drafts, action: action, artifacts: artifacts)
|
|
}
|
|
}
|
|
|
|
var TextEntry: some View {
|
|
ZStack(alignment: .topLeading) {
|
|
TextViewWrapper(
|
|
attributedText: $post,
|
|
textHeight: $textHeight,
|
|
initialTextSuffix: initial_text_suffix,
|
|
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(NSLocalizedString("Cancel", comment: "Button to cancel out of posting a note."))
|
|
.padding(10)
|
|
})
|
|
.buttonStyle(NeutralButtonStyle())
|
|
|
|
if let error {
|
|
Text(error)
|
|
.foregroundColor(.red)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
PostButton
|
|
}
|
|
|
|
if let progress = image_upload.progress {
|
|
ProgressView(value: progress, total: 1.0)
|
|
.progressViewStyle(.linear)
|
|
}
|
|
|
|
Divider()
|
|
.foregroundColor(DamusColors.neutral3)
|
|
.padding(.top, 5)
|
|
}
|
|
.frame(height: 30)
|
|
.padding()
|
|
.padding(.top, 15)
|
|
}
|
|
|
|
func handle_upload(media: MediaUpload) {
|
|
let uploader = damus_state.settings.default_media_uploader
|
|
Task {
|
|
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, keypair: damus_state.keypair)
|
|
|
|
switch res {
|
|
case .success(let url):
|
|
guard let url = URL(string: url) else {
|
|
self.error = "Error uploading image :("
|
|
return
|
|
}
|
|
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, representingImage: img, metadata: meta)
|
|
uploadedMedias.append(uploadedMedia)
|
|
|
|
case .failed(let error):
|
|
if let error {
|
|
self.error = error.localizedDescription
|
|
} else {
|
|
self.error = "Error uploading image :("
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
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, 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)
|
|
}
|
|
}
|
|
.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)
|
|
|
|
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 ? 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)
|
|
} else {
|
|
Divider()
|
|
VStack(alignment: .leading) {
|
|
AttachmentBar
|
|
.padding(.vertical, 5)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
}
|
|
.background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all))
|
|
.sheet(isPresented: $attach_media) {
|
|
ImagePicker(image_upload_confirm: $image_upload_confirm) { media in
|
|
self.mediaToUpload = media
|
|
}
|
|
.alert(NSLocalizedString("Are you sure you want to upload this 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) {
|
|
if let mediaToUpload {
|
|
self.handle_upload(media: mediaToUpload)
|
|
self.attach_media = false
|
|
}
|
|
}
|
|
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
|
|
}
|
|
}
|
|
.sheet(isPresented: $attach_camera) {
|
|
CameraController(uploader: damus_state.settings.default_media_uploader) {
|
|
self.attach_camera = false
|
|
self.attach_media = true
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
self.focus = true
|
|
}
|
|
}
|
|
.onDisappear {
|
|
if isEmpty() {
|
|
clear_draft()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
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]
|
|
|
|
let deviceWidth: CGFloat
|
|
|
|
var body: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack {
|
|
ForEach(media.map({$0.representingImage}), id: \.self) { image in
|
|
ZStack(alignment: .topTrailing) {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(width: media.count == 1 ? deviceWidth*0.8 : 250, height: media.count == 1 ? 400 : 250)
|
|
.cornerRadius(10)
|
|
.padding()
|
|
.contextMenu {
|
|
if let uploadedURL = media.first(where: { $0.representingImage == image })?.uploadedURL {
|
|
Button(action: {
|
|
UIPasteboard.general.string = uploadedURL.absoluteString
|
|
}) {
|
|
Label(NSLocalizedString("Copy URL", comment: "Label for button in context menu to copy URL of the selected uploaded media asset."), image: "copy")
|
|
}
|
|
}
|
|
}
|
|
Image("close-circle")
|
|
.foregroundColor(.white)
|
|
.padding(20)
|
|
.shadow(radius: 5)
|
|
.onTapGesture {
|
|
if let index = media.map({$0.representingImage}).firstIndex(of: image) {
|
|
media.remove(at: index)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
|
|
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 representingImage: UIImage
|
|
let metadata: ImageMetadata?
|
|
}
|
|
|
|
|
|
func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArtifacts) {
|
|
switch action {
|
|
case .replying_to(let ev):
|
|
drafts.replies[ev] = artifacts
|
|
case .quoting(let ev):
|
|
drafts.quotes[ev] = artifacts
|
|
case .posting:
|
|
drafts.post = artifacts
|
|
}
|
|
}
|
|
|
|
func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts? {
|
|
switch action {
|
|
case .replying_to(let ev):
|
|
return drafts.replies[ev]
|
|
case .quoting(let ev):
|
|
return drafts.quotes[ev]
|
|
case .posting:
|
|
return drafts.post
|
|
}
|
|
}
|
|
|
|
private func isAlphanumeric(_ char: Character) -> Bool {
|
|
return char.isLetter || char.isNumber
|
|
}
|
|
|
|
func build_post(state: DamusState, post: NSMutableAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId]) -> NostrPost {
|
|
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: CharacterSet.whitespacesAndNewlines)
|
|
|
|
let imagesString = uploadedMedias.map { $0.uploadedURL.absoluteString }.joined(separator: " ")
|
|
|
|
var tags = uploadedMedias.compactMap { $0.metadata?.to_tag() }
|
|
|
|
if !imagesString.isEmpty {
|
|
content.append(" " + imagesString + " ")
|
|
}
|
|
|
|
if case .quoting(let ev) = action {
|
|
content.append(" nostr:" + bech32_note_id(ev.id))
|
|
|
|
if let quoted_ev = state.events.lookup(ev.id) {
|
|
tags.append(["p", quoted_ev.pubkey.hex()])
|
|
}
|
|
}
|
|
|
|
return NostrPost(content: content, references: references, kind: .text, tags: tags)
|
|
}
|
|
|