20dc672dbf
This adds a sync mechanism in Ndb.swift to coordinate certain usage of nostrdb.c calls and the need to close nostrdb due to app lifecycle requirements. Furthermore, it fixes the order of operations when re-opening NostrDB, to avoid race conditions where a query uses an older Ndb generation. This sync mechanism allows multiple queries to happen simultaneously (from the Swift-side), while preventing ndb from simultaneously closing during such usages. It also does that while keeping the Ndb interface sync and nonisolated, which keeps the API easy to use from Swift/SwiftUI and allows for parallel operations to occur. If Swift Actors were to be used (e.g. creating an NdbActor), the Ndb.swift interface would change in such a way that it would propagate the need for several changes throughout the codebase, including loading logic in some ViewModels. Furthermore, it would likely decrease performance by forcing Ndb.swift operations to run sequentially when they could run in parallel. Changelog-Fixed: Fixed crashes that happened when the app went into background mode Closes: https://github.com/damus-io/damus/issues/3245 Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
515 lines
18 KiB
Swift
515 lines
18 KiB
Swift
//
|
||
// NoteContent.swift
|
||
// damus
|
||
//
|
||
// Created by Daniel D’Aquino 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 {
|
||
return try NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blocks in
|
||
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 {
|
||
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
|
||
hide_text_index = endIndex
|
||
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 = URL(string: url_block.as_str()) 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
|
||
}
|
||
else {
|
||
return .loopBreak
|
||
}
|
||
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
|
||
})
|
||
})
|
||
}
|
||
|
||
let txt: CompatibleText? = try? blocks.withList({ blocksList in
|
||
let endIndex = blocksList.count
|
||
return try blocksList.reduce(initialResult: CompatibleText(), { index, str, block in
|
||
|
||
// 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 index < 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 index >= 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: index + 1, { matchingBlock in
|
||
switch matchingBlock {
|
||
case .hashtag(_):
|
||
return str + CompatibleText(stringLiteral: reduce_text_block(ind: index, 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:
|
||
return .loopContinue
|
||
}
|
||
return .loopReturn(str)
|
||
}
|
||
}
|
||
|
||
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: index+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: index, 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 }
|
||
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 = try? profiles.lookup(id: pk)
|
||
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
|
||
}
|
||
}
|
||
}
|