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 }