Files
damus/damus/Components/SelectableText.swift
Daniel D’Aquino 24c3e61a4b Make drafts persistent
This commit makes drafts persistent.

It does so by:
1. Converting `DraftsArtifacts` into Nostr events
2. Wrapping those Nostr events into NIP-37 notes
3. Saving those NIP-37 notes into NostrDB
4. Loading those same notes at startup
5. Unwrapping NIP-37 notes into Nostr events
6. Parsing that into `DraftsArtifacts`, loaded into DamusState
7. PostView can then load these drafts

Furthermore, a UX indicator was added to show when a draft has been
saved.

Limitations:
1. No encoding/decoding roundtrip guarantees. That would require
   extensive and heavy refactoring which is out of the scope of this
   commit.
2. We rely on `UserSettings` to keep track of note ids, while we do not
   have Ndb query capabilities
3. No NIP-37 relay sync support has been added yet, as that adds
   important privacy and sync conflict considerations which are out of
   the scope of this ticket, which is ensuring people don't lose their
   progress while writing notes.
4. The main use cases and scenarios have been tested. Because of (1),
   there may be some small inconsistencies on the stored version of the
   draft, but care was taken to keep the substantial portions of the
   content intact.

Closes: https://github.com/damus-io/damus/issues/1862
Changelog-Added: Added local persistence of note drafts
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-01-24 10:36:46 -08:00

235 lines
8.5 KiB
Swift

//
// SelectableText.swift
// damus
//
// Created by Oleg Abalonski on 2/16/23.
//
import UIKit
import SwiftUI
struct SelectableText: View {
let damus_state: DamusState
let event: NostrEvent?
let attributedString: AttributedString
let textAlignment: NSTextAlignment
@State private var selectedTextActionState: SelectedTextActionState = .hide
@State private var selectedTextHeight: CGFloat = .zero
@State private var selectedTextWidth: CGFloat = .zero
let size: EventViewKind
init(damus_state: DamusState, event: NostrEvent?, attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) {
self.damus_state = damus_state
self.event = event
self.attributedString = attributedString
self.textAlignment = textAlignment ?? NSTextAlignment.natural
self.size = size
}
var body: some View {
GeometryReader { geo in
TextViewRepresentable(
attributedString: attributedString,
textColor: UIColor.label,
font: eventviewsize_to_uifont(size),
fixedWidth: selectedTextWidth,
textAlignment: self.textAlignment,
enableHighlighting: self.enableHighlighting(),
postHighlight: { selectedText in
self.selectedTextActionState = .show_highlight_post_view(highlighted_text: selectedText)
},
muteWord: { selectedText in
self.selectedTextActionState = .show_mute_word_view(highlighted_text: selectedText)
},
height: $selectedTextHeight
)
.padding([.leading, .trailing], -1.0)
.onAppear {
if geo.size.width == .zero {
self.selectedTextHeight = 1000.0
} else {
self.selectedTextWidth = geo.size.width
}
}
.onChange(of: geo.size) { newSize in
self.selectedTextWidth = newSize.width
}
}
.sheet(isPresented: Binding(get: {
return self.selectedTextActionState.should_show_highlight_post_view()
}, set: { newValue in
self.selectedTextActionState = newValue ? .show_highlight_post_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
})) {
if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState {
PostView(
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event.id))),
damus_state: damus_state
)
.presentationDragIndicator(.visible)
.presentationDetents([.height(selectedTextHeight + 450), .medium, .large])
}
}
.sheet(isPresented: Binding(get: {
return self.selectedTextActionState.should_show_mute_word_view()
}, set: { newValue in
self.selectedTextActionState = newValue ? .show_mute_word_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
})) {
if case .show_mute_word_view(let highlighted_text) = selectedTextActionState {
AddMuteItemView(state: damus_state, new_text: .constant(highlighted_text))
.presentationDragIndicator(.visible)
.presentationDetents([.height(300), .medium, .large])
}
}
.frame(height: selectedTextHeight)
}
func enableHighlighting() -> Bool {
self.event != nil
}
enum SelectedTextActionState {
case hide
case show_highlight_post_view(highlighted_text: String)
case show_mute_word_view(highlighted_text: String)
func should_show_highlight_post_view() -> Bool {
guard case .show_highlight_post_view(let highlighted_text) = self else { return false }
return true
}
func should_show_mute_word_view() -> Bool {
guard case .show_mute_word_view(let highlighted_text) = self else { return false }
return true
}
func highlighted_text() -> String? {
switch self {
case .hide:
return nil
case .show_mute_word_view(highlighted_text: let highlighted_text):
return highlighted_text
case .show_highlight_post_view(highlighted_text: let highlighted_text):
return highlighted_text
}
}
}
}
fileprivate class TextView: UITextView {
var postHighlight: (String) -> Void
var muteWord: (String) -> Void
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void) {
self.postHighlight = postHighlight
self.muteWord = muteWord
super.init(frame: frame, textContainer: textContainer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(highlightText(_:)) {
return true
}
if action == #selector(muteText(_:)) {
return true
}
return super.canPerformAction(action, withSender: sender)
}
func getSelectedText() -> String? {
guard let selectedRange = self.selectedTextRange else { return nil }
return self.text(in: selectedRange)
}
@objc public func highlightText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return }
self.postHighlight(selectedText)
}
@objc public func muteText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return }
self.muteWord(selectedText)
}
}
fileprivate struct TextViewRepresentable: UIViewRepresentable {
let attributedString: AttributedString
let textColor: UIColor
let font: UIFont
let fixedWidth: CGFloat
let textAlignment: NSTextAlignment
let enableHighlighting: Bool
let postHighlight: (String) -> Void
let muteWord: (String) -> Void
@Binding var height: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord)
view.isEditable = false
view.dataDetectorTypes = .all
view.isSelectable = true
view.backgroundColor = .clear
view.textContainer.lineFragmentPadding = 0
view.textContainerInset = .zero
view.textContainerInset.left = 1.0
view.textContainerInset.right = 1.0
view.textAlignment = textAlignment
let menuController = UIMenuController.shared
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:)))
menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : []
return view
}
func updateUIView(_ uiView: TextView, context: UIViewRepresentableContext<Self>) {
let mutableAttributedString = createNSAttributedString()
uiView.attributedText = mutableAttributedString
uiView.textAlignment = self.textAlignment
let newHeight = mutableAttributedString.height(containerWidth: fixedWidth)
DispatchQueue.main.async {
height = newHeight
}
}
func createNSAttributedString() -> NSMutableAttributedString {
let mutableAttributedString = NSMutableAttributedString(attributedString)
let myAttribute = [
NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor: textColor
]
mutableAttributedString.addAttributes(
myAttribute,
range: NSRange.init(location: 0, length: mutableAttributedString.length)
)
return mutableAttributedString
}
}
fileprivate extension NSAttributedString {
func height(containerWidth: CGFloat) -> CGFloat {
let rect = self.boundingRect(
with: CGSize.init(width: containerWidth, height: CGFloat.greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil
)
return ceil(rect.size.height)
}
}