Files
damus/damus/Features/Events/Models/NoteContent.swift
Daniel D’Aquino e9f4cbe881 Make NdbBlock ~Copyable for better lifetime safety
Changelog-None
Closes: https://github.com/damus-io/damus/issues/3127
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00

526 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// NoteContent.swift
// damus
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
import MarkdownUI
import UIKit
struct NoteArtifactsSeparated: Equatable {
static func == (lhs: NoteArtifactsSeparated, rhs: NoteArtifactsSeparated) -> Bool {
return lhs.content == rhs.content
}
let content: CompatibleText
let words: Int
let urls: [UrlType]
let invoices: [Invoice]
var media: [MediaUrl] {
return urls.compactMap { url in url.is_media }
}
var images: [URL] {
return urls.compactMap { url in url.is_img }
}
var links: [URL] {
return urls.compactMap { url in url.is_link }
}
static func just_content(_ content: String) -> NoteArtifactsSeparated {
let txt = CompatibleText(attributed: AttributedString(stringLiteral: content))
return NoteArtifactsSeparated(content: txt, words: 0, urls: [], invoices: [])
}
}
enum NoteArtifactState {
case not_loaded
case loading
case loaded(NoteArtifacts)
var artifacts: NoteArtifacts? {
if case .loaded(let artifacts) = self {
return artifacts
}
return nil
}
var should_preload: Bool {
switch self {
case .loaded:
return false
case .loading:
return false
case .not_loaded:
return true
}
}
}
func note_artifact_is_separated(kind: NostrKind?) -> Bool {
return kind != .longform
}
func render_immediately_available_note_content(ndb: Ndb, ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> NoteArtifacts {
if ev.known_kind == .longform {
return .longform(LongformContent(ev.content))
}
do {
let blocks = try NdbBlockGroup.from(event: ev, using: ndb, and: keypair)
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
}
catch {
// TODO: Improve error handling in the future, bubbling it up so that the view can decide how display errors. Keep legacy behavior for now.
return .separated(.just_content(ev.get_content(keypair)))
}
}
actor ContentRenderer {
func render_note_content(ndb: Ndb, ev: NostrEvent, profiles: Profiles, keypair: Keypair) async -> NoteArtifacts {
if ev.known_kind == .dm {
// Use the enhanced render_immediately_available_note_content which now handles DMs properly
// by decrypting and parsing the content with ndb_parse_content
return render_immediately_available_note_content(ndb: ndb, ev: ev, profiles: profiles, keypair: keypair)
}
let result = try? await ndb.waitFor(noteId: ev.id, timeout: 3)
return render_immediately_available_note_content(ndb: ndb, ev: ev, profiles: profiles, keypair: keypair)
}
}
// FIXME(tyiu): There are a lot of hacks to get this function to render the blocks correctly.
// However, the entire note content rendering logic just needs to be rewritten.
// Block previews should actually be rendered in the position of the note content where it was found.
// Currently, we put some previews at the bottom of the note, which is incorrect as they take things out of
// the author's intended context.
func render_blocks(blocks: borrowing NdbBlockGroup, profiles: Profiles, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated {
var invoices: [Invoice] = []
var urls: [UrlType] = []
var end_mention_count = 0
var end_url_count = 0
let note_ref_count: Int? = try? blocks.reduce(initialResult: 0) { index, partialResult, item in
switch item {
case .mention(let mention):
if let typ = mention.bech32_type,
typ.is_notelike {
return .loopReturn(partialResult + 1)
}
default:
break
}
return .loopContinue
}
let one_note_ref = note_ref_count == 1
// Search backwards until we find the beginning index of the chain of previewables that reach the end of the content.
var hide_text_index: Int = 0
if can_hide_last_previewable_refs {
let _: ()? = blocks.withList({ blocksList in
let endIndex = blocksList.count
return blocksList.forEachItemReversed({ index, block in
if block.is_previewable {
switch block {
case .mention:
end_mention_count += 1
// If there is more than one previewable mention,
// do not hide anything because we allow rich rendering of only one mention currently.
// This should be fixed in the future to show events inline instead.
if end_mention_count > 1 {
hide_text_index = endIndex
return .loopBreak
}
case .url(let url_block):
guard let url_string = NdbBlock.convertToStringCopy(from: url_block),
let url = URL(string: url_string) else {
return .loopContinue // We can't classify this, ignore and move on
}
let url_type = classify_url(url)
if case .link = url_type {
end_url_count += 1
// If there is more than one link, do not hide anything because we allow rich rendering of only
// one link.
if end_url_count > 1 {
hide_text_index = endIndex
return .loopBreak
}
}
default:
break
}
hide_text_index = index
}
else {
switch block {
case .text(let txt_block):
if let txt = NdbBlock.convertToStringCopy(from: txt_block),
txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
// We should hide whitespace at the end sequence.
hide_text_index = index
}
case .hashtag(_):
// SPECIAL CASE:
// We should keep hashtags at the end sequence but hide all the other previewables around it.
hide_text_index = index
default:
return .loopBreak
}
}
return .loopContinue
})
})
}
var ind: Int = -1
let txt: CompatibleText? = try? blocks.withList({ blocksList in
let endIndex = blocksList.count
return try blocksList.reduce(initialResult: CompatibleText(), { index, str, block in
ind = ind + 1
// Add the rendered previewable blocks to their type-specific lists.
switch block {
case .url(let url_block):
guard let url_string = NdbBlock.convertToStringCopy(from: url_block),
let url = URL(string: url_string) else {
break // We can't classify this, ignore and move on
}
let url_type = classify_url(url)
urls.append(url_type)
case .invoice(let invoice_block):
guard let invoice = invoice_block.as_invoice() else { break }
invoices.append(invoice)
default:
break
}
if can_hide_last_previewable_refs {
// If there are previewable blocks that occur before the consecutive sequence of them at the end of the content,
// we should not hide the text representation of any previewable block to avoid altering the format of the note.
if ind < hide_text_index && block.is_previewable {
hide_text_index = endIndex
}
// No need to show the text representation of the block if the only previewables are the sequence of them
// found at the end of the content.
// This is to save unnecessary use of screen space.
// The only exception is that if there are hashtags embedded in the end sequence, which is not uncommon,
// then we still want to show those hashtags but hide everything else that is previewable in the end sequence.
if ind >= hide_text_index {
switch block {
case .text(let txt_block):
if let txt = NdbBlock.convertToStringCopy(from: txt_block),
txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let returnItem: CompatibleText? = blocksList.useItem(at: ind + 1, { matchingBlock in
switch matchingBlock {
case .hashtag(_):
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt))
default:
return nil
}
}) ?? nil
if let returnItem {
return .loopReturn(returnItem)
}
}
case .hashtag(let htag):
return .loopReturn(str + hashtag_str(htag.as_str()))
default:
break
}
}
}
switch block {
case .mention(let m):
if let typ = m.bech32_type, typ.is_notelike, one_note_ref {
return .loopContinue
}
guard let mention = MentionRef(block: m) else { return .loopContinue }
return .loopReturn(str + mention_str(.any(mention), profiles: profiles))
case .text(let txt):
var hide_text_index_argument = hide_text_index
blocksList.useItem(at: ind+1, { block in
switch block {
case .hashtag(_):
// SPECIAL CASE:
// Do not trim whitespaces from suffix if the following block is a hashtag.
// This is because of the code further up (see "SPECIAL CASE").
hide_text_index_argument = -1
default:
break
}
})
return .loopReturn(str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index_argument, txt: txt.as_str())))
case .hashtag(let htag):
return .loopReturn(str + hashtag_str(htag.as_str()))
case .invoice(let invoice):
guard let inv = invoice.as_invoice() else { return .loopContinue }
invoices.append(inv)
case .url(let url):
guard let url = URL(string: url.as_str()) else { return .loopContinue }
let url_type = classify_url(url)
switch url_type {
case .media:
urls.append(url_type)
case .link(let url):
urls.append(url_type)
return .loopReturn(str + url_str(url))
}
case .mention_index:
return .loopContinue
}
return .loopContinue
})
})
return NoteArtifactsSeparated(content: txt ?? CompatibleText(), words: blocks.words, urls: urls, invoices: invoices)
}
func reduce_text_block(ind: Int, hide_text_index: Int, txt: String) -> String {
var trimmed = txt
// Trim leading whitespaces.
if ind == 0 {
trimmed = trim_prefix(trimmed)
}
// Trim trailing whitespaces if the following blocks will be hidden or if this is the last block.
if ind == hide_text_index - 1 {
trimmed = trim_suffix(trimmed)
}
return trimmed
}
func invoice_str(_ invoice: Invoice) -> CompatibleText {
var attributedString = AttributedString(stringLiteral: abbrev_identifier(invoice.string))
attributedString.link = URL(string: "damus:lightning:\(invoice.string)")
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
}
func url_str(_ url: URL) -> CompatibleText {
var attributedString = AttributedString(stringLiteral: url.absoluteString)
attributedString.link = url
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
}
func classify_url(_ url: URL) -> UrlType {
let fileExtension = url.lastPathComponent.lowercased().components(separatedBy: ".").last
switch fileExtension {
case "png", "jpg", "jpeg", "gif", "webp":
return .media(.image(url))
case "mp4", "mov", "m3u8":
return .media(.video(url))
default:
return .link(url)
}
}
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
let attachment = NSTextAttachment()
attachment.image = img
let attachmentString = NSAttributedString(attachment: attachment)
let wrapped = AttributedString(attachmentString)
astr.append(wrapped)
}
func getDisplayName(pk: Pubkey, profiles: Profiles) -> String {
let profile_txn = profiles.lookup(id: pk, txn_name: "getDisplayName")
let profile = profile_txn?.unsafeUnownedValue
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
}
func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText {
let bech32String = Bech32Object.encode(m.ref.toBech32Object())
let display_str: String = {
switch m.ref.nip19 {
case .npub(let pk): return getDisplayName(pk: pk, profiles: profiles)
case .note: return abbrev_identifier(bech32String)
case .nevent: return abbrev_identifier(bech32String)
case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
case .nrelay(let url): return url
case .naddr: return abbrev_identifier(bech32String)
case .nsec(let prv):
guard let npub = privkey_to_pubkey(privkey: prv)?.npub else { return "nsec..." }
return abbrev_identifier(npub)
case .nscript(_): return bech32String
}
}()
let display_str_with_at = "@\(display_str)"
var attributedString = AttributedString(stringLiteral: display_str_with_at)
attributedString.link = URL(string: "damus:nostr:\(bech32String)")
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
}
// trim suffix whitespace and newlines
func trim_suffix(_ str: String) -> String {
var result = str
while result.last?.isWhitespace == true {
result.removeLast()
}
return result
}
// trim prefix whitespace and newlines
func trim_prefix(_ str: String) -> String {
var result = str
while result.first?.isWhitespace == true {
result.removeFirst()
}
return result
}
struct LongformContent {
let markdown: MarkdownContent
let words: Int
init(_ markdown: String) {
let blocks = [BlockNode].init(markdown: markdown)
self.markdown = MarkdownContent(blocks: blocks)
self.words = count_markdown_words(blocks: blocks)
}
}
func count_markdown_words(blocks: [BlockNode]) -> Int {
return blocks.reduce(0) { words, block in
switch block {
case .paragraph(let content):
return words + count_inline_nodes_words(nodes: content)
case .blockquote, .bulletedList, .numberedList, .taskList, .codeBlock, .htmlBlock, .heading, .table, .thematicBreak:
return words
}
}
}
func count_words(_ s: String) -> Int {
return s.components(separatedBy: .whitespacesAndNewlines).count
}
func count_inline_nodes_words(nodes: [InlineNode]) -> Int {
return nodes.reduce(0) { words, node in
switch node {
case .text(let words):
return count_words(words)
case .emphasis(let children):
return words + count_inline_nodes_words(nodes: children)
case .strong(let children):
return words + count_inline_nodes_words(nodes: children)
case .strikethrough(let children):
return words + count_inline_nodes_words(nodes: children)
case .softBreak, .lineBreak, .code, .html, .image, .link:
return words
}
}
}
enum NoteArtifacts {
case separated(NoteArtifactsSeparated)
case longform(LongformContent)
var images: [URL] {
switch self {
case .separated(let arts):
return arts.images
case .longform:
return []
}
}
}
enum UrlType {
case media(MediaUrl)
case link(URL)
var url: URL {
switch self {
case .media(let media_url):
switch media_url {
case .image(let url):
return url
case .video(let url):
return url
}
case .link(let url):
return url
}
}
var is_video: URL? {
switch self {
case .media(let media_url):
switch media_url {
case .image:
return nil
case .video(let url):
return url
}
case .link:
return nil
}
}
var is_img: URL? {
switch self {
case .media(let media_url):
switch media_url {
case .image(let url):
return url
case .video:
return nil
}
case .link:
return nil
}
}
var is_link: URL? {
switch self {
case .media:
return nil
case .link(let url):
return url
}
}
var is_media: MediaUrl? {
switch self {
case .media(let murl):
return murl
case .link:
return nil
}
}
}
enum MediaUrl {
case image(URL)
case video(URL)
var url: URL {
switch self {
case .image(let url):
return url
case .video(let url):
return url
}
}
}