damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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 }