damus

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

NoteContent.swift (10002B)


      1 //
      2 //  NoteContent.swift
      3 //  damus
      4 //
      5 //  Created by Daniel D’Aquino on 2023-11-24.
      6 //
      7 
      8 import Foundation
      9 import MarkdownUI
     10 import UIKit
     11 
     12 struct NoteArtifactsSeparated: Equatable {
     13     static func == (lhs: NoteArtifactsSeparated, rhs: NoteArtifactsSeparated) -> Bool {
     14         return lhs.content == rhs.content
     15     }
     16     
     17     let content: CompatibleText
     18     let words: Int
     19     let urls: [UrlType]
     20     let invoices: [Invoice]
     21     
     22     var media: [MediaUrl] {
     23         return urls.compactMap { url in url.is_media }
     24     }
     25     
     26     var images: [URL] {
     27         return urls.compactMap { url in url.is_img }
     28     }
     29     
     30     var links: [URL] {
     31         return urls.compactMap { url in url.is_link }
     32     }
     33     
     34     static func just_content(_ content: String) -> NoteArtifactsSeparated {
     35         let txt = CompatibleText(attributed: AttributedString(stringLiteral: content))
     36         return NoteArtifactsSeparated(content: txt, words: 0, urls: [], invoices: [])
     37     }
     38 }
     39 
     40 enum NoteArtifactState {
     41     case not_loaded
     42     case loading
     43     case loaded(NoteArtifacts)
     44     
     45     var artifacts: NoteArtifacts? {
     46         if case .loaded(let artifacts) = self {
     47             return artifacts
     48         }
     49         
     50         return nil
     51     }
     52     
     53     var should_preload: Bool {
     54         switch self {
     55         case .loaded:
     56             return false
     57         case .loading:
     58             return false
     59         case .not_loaded:
     60             return true
     61         }
     62     }
     63 }
     64 
     65 func note_artifact_is_separated(kind: NostrKind?) -> Bool {
     66     return kind != .longform
     67 }
     68 
     69 func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> NoteArtifacts {
     70     let blocks = ev.blocks(keypair)
     71 
     72     if ev.known_kind == .longform {
     73         return .longform(LongformContent(ev.content))
     74     }
     75     
     76     return .separated(render_blocks(blocks: blocks, profiles: profiles))
     77 }
     78 
     79 func render_blocks(blocks bs: Blocks, profiles: Profiles) -> NoteArtifactsSeparated {
     80     var invoices: [Invoice] = []
     81     var urls: [UrlType] = []
     82     let blocks = bs.blocks
     83     
     84     let one_note_ref = blocks
     85         .filter({
     86             if case .mention(let mention) = $0,
     87                case .note = mention.ref {
     88                 return true
     89             }
     90             else {
     91                 return false
     92             }
     93         })
     94         .count == 1
     95     
     96     var ind: Int = -1
     97     let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in
     98         ind = ind + 1
     99         
    100         switch block {
    101         case .mention(let m):
    102             if case .note = m.ref, one_note_ref {
    103                 return str
    104             }
    105             return str + mention_str(m, profiles: profiles)
    106         case .text(let txt):
    107             return str + CompatibleText(stringLiteral: reduce_text_block(blocks: blocks, ind: ind, txt: txt, one_note_ref: one_note_ref))
    108 
    109         case .relay(let relay):
    110             return str + CompatibleText(stringLiteral: relay)
    111             
    112         case .hashtag(let htag):
    113             return str + hashtag_str(htag)
    114         case .invoice(let invoice):
    115             invoices.append(invoice)
    116             return str
    117         case .url(let url):
    118             let url_type = classify_url(url)
    119             switch url_type {
    120             case .media:
    121                 urls.append(url_type)
    122                 return str
    123             case .link(let url):
    124                 urls.append(url_type)
    125                 return str + url_str(url)
    126             }
    127         }
    128     }
    129 
    130     return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices)
    131 }
    132 
    133 func reduce_text_block(blocks: [Block], ind: Int, txt: String, one_note_ref: Bool) -> String {
    134     var trimmed = txt
    135     
    136     if let prev = blocks[safe: ind-1],
    137        case .url(let u) = prev,
    138        classify_url(u).is_media != nil {
    139         trimmed = " " + trim_prefix(trimmed)
    140     }
    141     
    142     if let next = blocks[safe: ind+1] {
    143         if case .url(let u) = next, classify_url(u).is_media != nil {
    144             trimmed = trim_suffix(trimmed)
    145         } else if case .mention(let m) = next,
    146                   case .note = m.ref,
    147                   one_note_ref {
    148             trimmed = trim_suffix(trimmed)
    149         }
    150     }
    151     
    152     return trimmed
    153 }
    154 
    155 func url_str(_ url: URL) -> CompatibleText {
    156     var attributedString = AttributedString(stringLiteral: url.absoluteString)
    157     attributedString.link = url
    158     attributedString.foregroundColor = DamusColors.purple
    159     
    160     return CompatibleText(attributed: attributedString)
    161 }
    162 
    163 func classify_url(_ url: URL) -> UrlType {
    164     let str = url.lastPathComponent.lowercased()
    165     
    166     if str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") {
    167         return .media(.image(url))
    168     }
    169     
    170     if str.hasSuffix(".mp4") || str.hasSuffix(".mov") || str.hasSuffix(".m3u8") {
    171         return .media(.video(url))
    172     }
    173     
    174     return .link(url)
    175 }
    176 
    177 func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
    178     let attachment = NSTextAttachment()
    179     attachment.image = img
    180     let attachmentString = NSAttributedString(attachment: attachment)
    181     let wrapped = AttributedString(attachmentString)
    182     astr.append(wrapped)
    183 }
    184 
    185 func getDisplayName(pk: Pubkey, profiles: Profiles) -> String {
    186     let profile_txn = profiles.lookup(id: pk, txn_name: "getDisplayName")
    187     let profile = profile_txn?.unsafeUnownedValue
    188     return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
    189 }
    190 
    191 func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText {
    192     let bech32String = Bech32Object.encode(m.ref.toBech32Object())
    193     
    194     let display_str: String = {
    195         switch m.ref {
    196         case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles)
    197         case .note: return abbrev_pubkey(bech32String)
    198         case .nevent: return abbrev_pubkey(bech32String)
    199         case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
    200         case .nrelay(let url): return url
    201         case .naddr: return abbrev_pubkey(bech32String)
    202         }
    203     }()
    204 
    205     let display_str_with_at = "@\(display_str)"
    206 
    207     var attributedString = AttributedString(stringLiteral: display_str_with_at)
    208     attributedString.link = URL(string: "damus:nostr:\(bech32String)")
    209     attributedString.foregroundColor = DamusColors.purple
    210     
    211     return CompatibleText(attributed: attributedString)
    212 }
    213 
    214 // trim suffix whitespace and newlines
    215 func trim_suffix(_ str: String) -> String {
    216     return str.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
    217 }
    218 
    219 // trim prefix whitespace and newlines
    220 func trim_prefix(_ str: String) -> String {
    221     return str.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
    222 }
    223 
    224 struct LongformContent {
    225     let markdown: MarkdownContent
    226     let words: Int
    227 
    228     init(_ markdown: String) {
    229         let blocks = [BlockNode].init(markdown: markdown)
    230         self.markdown = MarkdownContent(blocks: blocks)
    231         self.words = count_markdown_words(blocks: blocks)
    232     }
    233 }
    234 
    235 func count_markdown_words(blocks: [BlockNode]) -> Int {
    236     return blocks.reduce(0) { words, block in
    237         switch block {
    238         case .paragraph(let content):
    239             return words + count_inline_nodes_words(nodes: content)
    240         case .blockquote, .bulletedList, .numberedList, .taskList, .codeBlock, .htmlBlock, .heading, .table, .thematicBreak:
    241             return words
    242         }
    243     }
    244 }
    245 
    246 func count_words(_ s: String) -> Int {
    247     return s.components(separatedBy: .whitespacesAndNewlines).count
    248 }
    249 
    250 func count_inline_nodes_words(nodes: [InlineNode]) -> Int {
    251     return nodes.reduce(0) { words, node in
    252         switch node {
    253         case .text(let words):
    254             return count_words(words)
    255         case .emphasis(let children):
    256             return words + count_inline_nodes_words(nodes: children)
    257         case .strong(let children):
    258             return words + count_inline_nodes_words(nodes: children)
    259         case .strikethrough(let children):
    260             return words + count_inline_nodes_words(nodes: children)
    261         case .softBreak, .lineBreak, .code, .html, .image, .link:
    262             return words
    263         }
    264     }
    265 }
    266 
    267 enum NoteArtifacts {
    268     case separated(NoteArtifactsSeparated)
    269     case longform(LongformContent)
    270 
    271     var images: [URL] {
    272         switch self {
    273         case .separated(let arts):
    274             return arts.images
    275         case .longform:
    276             return []
    277         }
    278     }
    279 }
    280 
    281 enum UrlType {
    282     case media(MediaUrl)
    283     case link(URL)
    284     
    285     var url: URL {
    286         switch self {
    287         case .media(let media_url):
    288             switch media_url {
    289             case .image(let url):
    290                 return url
    291             case .video(let url):
    292                 return url
    293             }
    294         case .link(let url):
    295             return url
    296         }
    297     }
    298     
    299     var is_video: URL? {
    300         switch self {
    301         case .media(let media_url):
    302             switch media_url {
    303             case .image:
    304                 return nil
    305             case .video(let url):
    306                 return url
    307             }
    308         case .link:
    309             return nil
    310         }
    311     }
    312     
    313     var is_img: URL? {
    314         switch self {
    315         case .media(let media_url):
    316             switch media_url {
    317             case .image(let url):
    318                 return url
    319             case .video:
    320                 return nil
    321             }
    322         case .link:
    323             return nil
    324         }
    325     }
    326     
    327     var is_link: URL? {
    328         switch self {
    329         case .media:
    330             return nil
    331         case .link(let url):
    332             return url
    333         }
    334     }
    335     
    336     var is_media: MediaUrl? {
    337         switch self {
    338         case .media(let murl):
    339             return murl
    340         case .link:
    341             return nil
    342         }
    343     }
    344 }
    345 
    346 enum MediaUrl {
    347     case image(URL)
    348     case video(URL)
    349     
    350     var url: URL {
    351         switch self {
    352         case .image(let url):
    353             return url
    354         case .video(let url):
    355             return url
    356         }
    357     }
    358 }