DraftsModel.swift (14716B)
1 // 2 // DraftsModel.swift 3 // damus 4 // 5 // Created by Terry Yiu on 2/12/23. 6 // 7 8 import Foundation 9 import SwiftUICore 10 import UIKit 11 12 /// Represents artifacts in a post draft, which is rendered by `PostView` 13 /// 14 /// ## Implementation notes 15 /// 16 /// - This is NOT `Codable` because we store these persistently as NIP-37 drafts in NostrDB, instead of directly encoding the object. 17 /// - `NSMutableAttributedString` is the bottleneck for making this `Codable`, and replacing that with another type requires a very large refactor. 18 /// - Encoding/decoding logic is lossy, and is not fully round-trippable. This class does a best effort attempt at encoding and recovering as much information as possible, but the information is dispersed into many different places, types, and functions around the code, making round-trip guarantees very difficult without severely refactoring `PostView`, `TextViewWrapper`, and other associated classes, unfortunately. These are the known limitations at the moment: 19 /// - Image metadata is lost on decoding 20 /// - The `filtered_pubkeys` filter effectively gets applied upon encoding, causing them to change upon decoding 21 /// 22 class DraftArtifacts: Equatable { 23 /// The text content of the note draft 24 /// 25 /// ## Implementation notes 26 /// 27 /// - This serves as the backing model for `PostView` and `TextViewWrapper`. It might be cleaner to use a specialized data model for this in the future and render to attributed string in real time, but that will require a big refactor. See https://github.com/damus-io/damus/issues/1862#issuecomment-2585756932 28 var content: NSMutableAttributedString 29 /// A list of media items that have been attached to the note draft. 30 var media: [UploadedMedia] 31 /// The references for this note, which will be translated into tags once the event is published. 32 var references: [RefId] 33 /// Pubkeys that should be filtered out from the references 34 /// 35 /// For example, when replying to an event, the user can select which pubkey mentions they want to keep, and which ones to remove. 36 var filtered_pubkeys: Set<Pubkey> = [] 37 38 /// A unique ID for this draft that allows us to address these if we need to. 39 /// 40 /// This will be the unique identifier in the NIP-37 note 41 let id: String 42 43 init(content: NSMutableAttributedString = NSMutableAttributedString(string: ""), media: [UploadedMedia] = [], references: [RefId], id: String) { 44 self.content = content 45 self.media = media 46 self.references = references 47 self.id = id 48 } 49 50 static func == (lhs: DraftArtifacts, rhs: DraftArtifacts) -> Bool { 51 return ( 52 lhs.media == rhs.media && 53 lhs.content.string == rhs.content.string // Comparing the text content is not perfect but acceptable in this case because attributes for our post editor are determined purely from text content 54 ) 55 } 56 57 58 // MARK: Encoding and decoding functions to and from NIP-37 nostr events 59 60 /// Converts the draft artifacts into a NIP-37 draft event that can be saved into NostrDB or any Nostr relay 61 /// 62 /// - Parameters: 63 /// - action: The post action for this draft, which provides necessary context for the draft (e.g. Is it meant to highlight something? Reply to something?) 64 /// - damus_state: The damus state, needed for encrypting, fetching Nostr data depedencies, and forming the NIP-37 draft 65 /// - references: references in the post? 66 /// - Returns: The NIP-37 draft packaged in a way that can be easily wrapped/unwrapped. 67 func to_nip37_draft(action: PostAction, damus_state: DamusState) throws -> NIP37Draft? { 68 guard let keypair = damus_state.keypair.to_full() else { return nil } 69 let post = build_post(state: damus_state, action: action, draft: self) 70 guard let note = post.to_event(keypair: keypair) else { return nil } 71 return try NIP37Draft(unwrapped_note: note, draft_id: self.id, keypair: keypair) 72 } 73 74 /// Instantiates a draft object from a NIP-37 draft 75 /// - Parameters: 76 /// - nip37_draft: The NIP-37 draft object 77 /// - damus_state: Damus state of the user who wants to load this draft object. Needed for pulling profiles from Ndb, and decrypting contents. 78 /// - Returns: A draft artifacts object, or `nil` if such cannot be loaded. 79 static func from(nip37_draft: NIP37Draft, damus_state: DamusState) -> DraftArtifacts? { 80 return Self.from( 81 event: nip37_draft.unwrapped_note, 82 draft_id: nip37_draft.id ?? UUID().uuidString, // Generate random UUID as the draft ID if none is specified. It is always better to have an ID that we can use for addressing later. 83 damus_state: damus_state 84 ) 85 } 86 87 /// Load a draft artifacts object from a plain, unwrapped NostrEvent 88 /// 89 /// This function will parse the contents of a Nostr Event and turn it into an editable draft that we can use. 90 /// 91 /// - Parameters: 92 /// - event: The Nostr event to use as a template 93 /// - draft_id: The unique ID of this draft, used for keeping draft identities stable. UUIDs are recommended but not required. 94 /// - damus_state: The user's Damus state, used for fetching profiles in NostrDB 95 /// - Returns: The draft that can be loaded into `PostView`. 96 static func from(event: NostrEvent, draft_id: String, damus_state: DamusState) -> DraftArtifacts { 97 let parsed_blocks = parse_note_content(content: .init(note: event, keypair: damus_state.keypair)) 98 return Self.from(parsed_blocks: parsed_blocks, references: Array(event.references), draft_id: draft_id, damus_state: damus_state) 99 } 100 101 /// Load a draft artifacts object from parsed Nostr event blocks 102 /// 103 /// - Parameters: 104 /// - parsed_blocks: The blocks parsed from a Nostr event 105 /// - references: The references in the Nostr event 106 /// - draft_id: The unique ID of the draft as per NIP-37 107 /// - damus_state: Damus state, used for fetching profile info in NostrDB 108 /// - Returns: The draft that can be loaded into `PostView`. 109 static func from(parsed_blocks: Blocks, references: [RefId], draft_id: String, damus_state: DamusState) -> DraftArtifacts { 110 let rich_text_content: NSMutableAttributedString = .init(string: "") 111 var media: [UploadedMedia] = [] 112 for block in parsed_blocks.blocks { 113 switch block { 114 case .mention(let mention): 115 if case .pubkey(let pubkey) = mention.ref { 116 // A profile reference, format things properly. 117 let profile = damus_state.ndb.lookup_profile(pubkey)?.unsafeUnownedValue?.profile 118 let profile_name = DisplayName(profile: profile, pubkey: pubkey).username 119 guard let url_address = URL(string: block.asString) else { 120 rich_text_content.append(.init(string: block.asString)) 121 continue 122 } 123 let attributed_string = NSMutableAttributedString( 124 string: "@\(profile_name)", 125 attributes: [ 126 .link: url_address, 127 .foregroundColor: UIColor(Color.accentColor) 128 ] 129 ) 130 rich_text_content.append(attributed_string) 131 } 132 else if case .note(_) = mention.ref { 133 // These note references occur when we quote a note, and since that is tracked via `PostAction` in `PostView`, ignore it here to avoid attaching the same event twice in a note 134 continue 135 } 136 else { 137 // Other references 138 rich_text_content.append(.init(string: block.asString)) 139 } 140 case .url(let url): 141 if isSupportedImage(url: url) { 142 // Image, add that to our media attachments 143 // TODO: Add metadata decoding support 144 media.append(UploadedMedia(localURL: url, uploadedURL: url, metadata: .none)) 145 continue 146 } 147 else { 148 // Normal URL, plain text 149 rich_text_content.append(.init(string: block.asString)) 150 } 151 case .invoice(_), .relay(_), .hashtag(_), .text(_): 152 // Everything else is currently plain text. 153 rich_text_content.append(.init(string: block.asString)) 154 } 155 } 156 return DraftArtifacts(content: rich_text_content, media: media, references: references, id: draft_id) 157 } 158 } 159 160 161 /// Holds and keeps track of the note post drafts throughout the app. 162 class Drafts: ObservableObject { 163 @Published var post: DraftArtifacts? = nil 164 @Published var replies: [NoteId: DraftArtifacts] = [:] 165 @Published var quotes: [NoteId: DraftArtifacts] = [:] 166 /// The drafts we have for highlights 167 /// 168 /// ## Implementation notes 169 /// - Although in practice we also load drafts based on the highlight source for better UX (making it easier to find a draft), we need the keys to be of type `HighlightContentDraft` because we need the selected text information to be able to construct the NIP-37 draft, as well as to load that into post view. 170 @Published var highlights: [HighlightContentDraft: DraftArtifacts] = [:] 171 172 /// Loads drafts from storage (NostrDB + UserDefaults) 173 func load(from damus_state: DamusState) { 174 guard let note_ids = damus_state.settings.draft_event_ids?.compactMap({ NoteId(hex: $0) }) else { return } 175 for note_id in note_ids { 176 let txn = damus_state.ndb.lookup_note(note_id) 177 guard let note = txn?.unsafeUnownedValue else { continue } 178 // Implementation note: This currently fails silently, because: 179 // 1. Errors are unlikely and not expected 180 // 2. It is not mission critical to recover from this error 181 // 3. The changes that add a error view sheet with useful info is not yet merged in as of writing. 182 try? self.load(wrapped_draft_note: note, with: damus_state) 183 } 184 } 185 186 /// Loads a specific NIP-37 note into this class 187 func load(wrapped_draft_note: NdbNote, with damus_state: DamusState) throws { 188 // Extract draft info from the NIP-37 note 189 guard let full_keypair = damus_state.keypair.to_full() else { return } 190 guard let nip37_draft = try NIP37Draft(wrapped_note: wrapped_draft_note, keypair: full_keypair) else { return } 191 guard let known_kind = nip37_draft.unwrapped_note.known_kind else { return } 192 guard let draft_artifacts = DraftArtifacts.from( 193 nip37_draft: nip37_draft, 194 damus_state: damus_state 195 ) else { return } 196 197 // Find out where to place these drafts 198 let blocks = parse_note_content(content: .note(nip37_draft.unwrapped_note)) 199 switch known_kind { 200 case .text: 201 if let replied_to_note_id = nip37_draft.unwrapped_note.direct_replies() { 202 self.replies[replied_to_note_id] = draft_artifacts 203 } 204 else { 205 for block in blocks.blocks { 206 if case .mention(let mention) = block { 207 if case .note(let note_id) = mention.ref { 208 self.quotes[note_id] = draft_artifacts 209 return 210 } 211 } 212 } 213 self.post = draft_artifacts 214 } 215 case .highlight: 216 guard let highlight = HighlightContentDraft(from: nip37_draft.unwrapped_note) else { return } 217 self.highlights[highlight] = draft_artifacts 218 default: 219 return 220 } 221 } 222 223 /// Saves the drafts tracked by this class persistently using NostrDB + UserDefaults 224 func save(damus_state: DamusState) { 225 var draft_events: [NdbNote] = [] 226 post_artifact_block: if let post_artifacts = self.post { 227 let nip37_draft = try? post_artifacts.to_nip37_draft(action: .posting(.user(damus_state.pubkey)), damus_state: damus_state) 228 guard let wrapped_note = nip37_draft?.wrapped_note else { break post_artifact_block } 229 draft_events.append(wrapped_note) 230 } 231 for (replied_to_note_id, reply_artifacts) in self.replies { 232 guard let replied_to_note = damus_state.ndb.lookup_note(replied_to_note_id)?.unsafeUnownedValue?.to_owned() else { continue } 233 let nip37_draft = try? reply_artifacts.to_nip37_draft(action: .replying_to(replied_to_note), damus_state: damus_state) 234 guard let wrapped_note = nip37_draft?.wrapped_note else { continue } 235 draft_events.append(wrapped_note) 236 } 237 for (quoted_note_id, quote_note_artifacts) in self.quotes { 238 guard let quoted_note = damus_state.ndb.lookup_note(quoted_note_id)?.unsafeUnownedValue?.to_owned() else { continue } 239 let nip37_draft = try? quote_note_artifacts.to_nip37_draft(action: .quoting(quoted_note), damus_state: damus_state) 240 guard let wrapped_note = nip37_draft?.wrapped_note else { continue } 241 draft_events.append(wrapped_note) 242 } 243 for (highlight, highlight_note_artifacts) in self.highlights { 244 let nip37_draft = try? highlight_note_artifacts.to_nip37_draft(action: .highlighting(highlight), damus_state: damus_state) 245 guard let wrapped_note = nip37_draft?.wrapped_note else { continue } 246 draft_events.append(wrapped_note) 247 } 248 249 for draft_event in draft_events { 250 // Implementation note: We do not support draft synchronization with relays yet. 251 // TODO: Once it is time to implement draft syncing with relays, please consider the following: 252 // - Privacy: Sending drafts to the network leaks metadata about app activity, and may break user expectations 253 // - Down-sync conflict resolution: Consider how to solve conflicts for different draft versions holding the same ID (e.g. edited in Damus, then another client, then Damus again) 254 damus_state.pool.send_raw_to_local_ndb(.typical(.event(draft_event))) 255 } 256 257 damus_state.settings.draft_event_ids = draft_events.map({ $0.id.hex() }) 258 } 259 } 260 261 // MARK: - Convenience extensions 262 263 fileprivate extension Array { 264 mutating func appendIfNotNil(_ element: Element?) { 265 if let element = element { 266 self.append(element) 267 } 268 } 269 }