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>
This commit is contained in:
Daniel D’Aquino
2025-07-04 11:48:50 -07:00
parent 91abd187d3
commit e9f4cbe881
8 changed files with 452 additions and 221 deletions

View File

@@ -1571,6 +1571,10 @@
D74EA0932D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */; }; D74EA0932D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */; };
D74EA0942D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */; }; D74EA0942D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */; };
D74EA0952D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */; }; D74EA0952D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */; };
D74EC84F2E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */; };
D74EC8502E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */; };
D74EC8512E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */; };
D74EC8522E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */; };
D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F43092B23F0BE00425B75 /* DamusPurple.swift */; }; D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F43092B23F0BE00425B75 /* DamusPurple.swift */; };
D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.swift */; }; D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.swift */; };
D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D753CEA92BE9DE04001C3A5D /* MutingTests.swift */; }; D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D753CEA92BE9DE04001C3A5D /* MutingTests.swift */; };
@@ -2627,6 +2631,7 @@
D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HumanReadableErrors.swift; sourceTree = "<group>"; }; D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HumanReadableErrors.swift; sourceTree = "<group>"; };
D74EA08D2D2E271E002290DD /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; }; D74EA08D2D2E271E002290DD /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventView.swift; sourceTree = "<group>"; }; D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventView.swift; sourceTree = "<group>"; };
D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonCopyableLinkedList.swift; sourceTree = "<group>"; };
D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = "<group>"; }; D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = "<group>"; };
D74F430B2B23FB9B00425B75 /* StoreObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreObserver.swift; sourceTree = "<group>"; }; D74F430B2B23FB9B00425B75 /* StoreObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreObserver.swift; sourceTree = "<group>"; };
D753CEA92BE9DE04001C3A5D /* MutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutingTests.swift; sourceTree = "<group>"; }; D753CEA92BE9DE04001C3A5D /* MutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutingTests.swift; sourceTree = "<group>"; };
@@ -3154,6 +3159,7 @@
4C9054862A6AEB4500811EEC /* nostrdb */ = { 4C9054862A6AEB4500811EEC /* nostrdb */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */,
D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */, D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */,
D7F5630F2DEE71BB008509DE /* NdbFilter.swift */, D7F5630F2DEE71BB008509DE /* NdbFilter.swift */,
D74DEC892DA0A19800E69FA6 /* Ndb+.swift */, D74DEC892DA0A19800E69FA6 /* Ndb+.swift */,
@@ -5764,6 +5770,7 @@
4C3BEFD6281D995700B3DE84 /* ActionBarModel.swift in Sources */, 4C3BEFD6281D995700B3DE84 /* ActionBarModel.swift in Sources */,
4C7D09762A0AF19E00943473 /* FillAndStroke.swift in Sources */, 4C7D09762A0AF19E00943473 /* FillAndStroke.swift in Sources */,
4CA927612A290E340098A105 /* EventShell.swift in Sources */, 4CA927612A290E340098A105 /* EventShell.swift in Sources */,
D74EC8502E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */,
4C363AA428296DEE006E126D /* SearchModel.swift in Sources */, 4C363AA428296DEE006E126D /* SearchModel.swift in Sources */,
4C8D00CC29DF92DF0036AF10 /* Hashtags.swift in Sources */, 4C8D00CC29DF92DF0036AF10 /* Hashtags.swift in Sources */,
D74EA0942D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */, D74EA0942D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */,
@@ -6268,6 +6275,7 @@
82D6FBB02CD99F7900C925F4 /* RelayConnection.swift in Sources */, 82D6FBB02CD99F7900C925F4 /* RelayConnection.swift in Sources */,
82D6FBB12CD99F7900C925F4 /* RelayLog.swift in Sources */, 82D6FBB12CD99F7900C925F4 /* RelayLog.swift in Sources */,
82D6FBB22CD99F7900C925F4 /* Nostr.swift in Sources */, 82D6FBB22CD99F7900C925F4 /* Nostr.swift in Sources */,
D74EC8512E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */,
82D6FBB32CD99F7900C925F4 /* NostrFilter.swift in Sources */, 82D6FBB32CD99F7900C925F4 /* NostrFilter.swift in Sources */,
82D6FBB42CD99F7900C925F4 /* NostrResponse.swift in Sources */, 82D6FBB42CD99F7900C925F4 /* NostrResponse.swift in Sources */,
82D6FBB52CD99F7900C925F4 /* NostrEvent.swift in Sources */, 82D6FBB52CD99F7900C925F4 /* NostrEvent.swift in Sources */,
@@ -6570,6 +6578,7 @@
D73E5E622C6A97F4007EB227 /* BlurHashEncode.swift in Sources */, D73E5E622C6A97F4007EB227 /* BlurHashEncode.swift in Sources */,
5C09FD122DF283D700823661 /* FollowPackModel.swift in Sources */, 5C09FD122DF283D700823661 /* FollowPackModel.swift in Sources */,
D73E5E632C6A97F4007EB227 /* BlurHashDecode.swift in Sources */, D73E5E632C6A97F4007EB227 /* BlurHashDecode.swift in Sources */,
D74EC8522E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */,
D73E5F952C6AA753007EB227 /* FullScreenCarouselView.swift in Sources */, D73E5F952C6AA753007EB227 /* FullScreenCarouselView.swift in Sources */,
D76BE18E2E0CF3DA004AD0C6 /* Interests.swift in Sources */, D76BE18E2E0CF3DA004AD0C6 /* Interests.swift in Sources */,
D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */, D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */,
@@ -7020,6 +7029,7 @@
4CBB6F792B7311AA000477A4 /* error.c in Sources */, 4CBB6F792B7311AA000477A4 /* error.c in Sources */,
4CBB6F7A2B7311AA000477A4 /* bech32_util.c in Sources */, 4CBB6F7A2B7311AA000477A4 /* bech32_util.c in Sources */,
4CBB6F712B731184000477A4 /* bolt11.c in Sources */, 4CBB6F712B731184000477A4 /* bolt11.c in Sources */,
D74EC84F2E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */,
4CBB6F702B731179000477A4 /* invoice.c in Sources */, 4CBB6F702B731179000477A4 /* invoice.c in Sources */,
4CBB6F6F2B73116B000477A4 /* content_parser.c in Sources */, 4CBB6F6F2B73116B000477A4 /* content_parser.c in Sources */,
4CBB6F6E2B731113000477A4 /* block.c in Sources */, 4CBB6F6E2B731113000477A4 /* block.c in Sources */,

View File

@@ -783,49 +783,42 @@ func first_eref_mention(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> Mention<N
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else { guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else {
return nil return nil
} }
let blocks = blockGroup.blocks.filter { block in
guard case .mention(let mention) = block else {
return false
}
switch mention.bech32_type {
case .note, .nevent:
return true
default:
return false
}
}
/// MARK: - Preview return try? blockGroup.forEachBlock({ index, block in
if let firstBlock = blocks.first, // Step 1: Filter
case .mention(let mention) = firstBlock { switch block {
switch mention.bech32_type { case .mention(let mention):
case .note: switch mention.bech32_type {
let data = mention.bech32.note.event_id.as_data(size: 32) case .note:
return .note(NoteId(data)) let data = mention.bech32.note.event_id.as_data(size: 32)
case .nevent: return .loopReturn(.note(NoteId(data)))
let data = mention.bech32.nevent.event_id.as_data(size: 32) case .nevent:
return .note(NoteId(data)) let data = mention.bech32.nevent.event_id.as_data(size: 32)
return .loopReturn(.note(NoteId(data)))
default:
return .loopBreak
}
default: default:
return nil return .loopContinue
} }
} })
return nil
} }
func separate_invoices(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [Invoice]? { func separate_invoices(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [Invoice]? {
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else { guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else {
return nil return nil
} }
let invoiceBlocks: [Invoice] = blockGroup.blocks.reduce(into: []) { invoices, block in let invoiceBlocks: [Invoice] = (try? blockGroup.reduce(initialResult: [Invoice](), { index, invoices, block in
guard case .invoice(let invoice) = block, switch block {
let invoice = invoice.as_invoice() case .invoice(let invoice):
else { if let invoice = invoice.as_invoice() {
return return .loopReturn(invoices + [invoice])
}
default:
break
} }
invoices.append(invoice) return .loopContinue
} })) ?? []
return invoiceBlocks.isEmpty ? nil : invoiceBlocks return invoiceBlocks.isEmpty ? nil : invoiceBlocks
} }

View File

@@ -105,152 +105,183 @@ func render_blocks(blocks: borrowing NdbBlockGroup, profiles: Profiles, can_hide
var end_mention_count = 0 var end_mention_count = 0
var end_url_count = 0 var end_url_count = 0
let ndb_blocks = blocks.blocks let note_ref_count: Int? = try? blocks.reduce(initialResult: 0) { index, partialResult, item in
let one_note_ref = ndb_blocks switch item {
.filter({ case .mention(let mention):
if case .mention(let mention) = $0, if let typ = mention.bech32_type,
let typ = mention.bech32_type,
typ.is_notelike { typ.is_notelike {
return true return .loopReturn(partialResult + 1)
} }
return false
})
.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 = ndb_blocks.endIndex
if can_hide_last_previewable_refs {
outerLoop: for (i, block) in ndb_blocks.enumerated().reversed() {
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 = ndb_blocks.endIndex
break outerLoop
}
case .url(let url_block):
guard let url_string = NdbBlock.convertToStringCopy(from: url_block),
let url = URL(string: url_string) else {
continue // 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 = ndb_blocks.endIndex
break outerLoop
}
}
default:
break
}
hide_text_index = i
} else if case .text(let txt_block) = block,
let txt = NdbBlock.convertToStringCopy(from: txt_block),
txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
// We should hide whitespace at the end sequence.
hide_text_index = i
} else if case .hashtag = block {
// SPECIAL CASE:
// We should keep hashtags at the end sequence but hide all the other previewables around it.
hide_text_index = i
} else {
break
}
}
}
var ind: Int = -1
let txt: CompatibleText = ndb_blocks.reduce(into: CompatibleText()) { 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: default:
break break
} }
return .loopContinue
}
let one_note_ref = note_ref_count == 1
if can_hide_last_previewable_refs { // Search backwards until we find the beginning index of the chain of previewables that reach the end of the content.
// If there are previewable blocks that occur before the consecutive sequence of them at the end of the content, var hide_text_index: Int = 0
// we should not hide the text representation of any previewable block to avoid altering the format of the note. if can_hide_last_previewable_refs {
if ind < hide_text_index && block.is_previewable { let _: ()? = blocks.withList({ blocksList in
hide_text_index = ndb_blocks.endIndex let endIndex = blocksList.count
} return blocksList.forEachItemReversed({ index, block in
if block.is_previewable {
// No need to show the text representation of the block if the only previewables are the sequence of them switch block {
// found at the end of the content. case .mention:
// This is to save unnecessary use of screen space. end_mention_count += 1
// 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 there is more than one previewable mention,
if ind >= hide_text_index { // do not hide anything because we allow rich rendering of only one mention currently.
if case .text(let txt_block) = block, // This should be fixed in the future to show events inline instead.
let txt = NdbBlock.convertToStringCopy(from: txt_block), if end_mention_count > 1 {
txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { hide_text_index = endIndex
if case .hashtag = ndb_blocks[safe: ind+1] { return .loopBreak
str = str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt)) }
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
} }
} else if case .hashtag(let htag) = block { hide_text_index = index
str = str + hashtag_str(htag.as_str())
} }
return else {
} switch block {
} case .text(let txt_block):
if let txt = NdbBlock.convertToStringCopy(from: txt_block),
switch block { txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
case .mention(let m): // We should hide whitespace at the end sequence.
if let typ = m.bech32_type, typ.is_notelike, one_note_ref { hide_text_index = index
return }
} case .hashtag(_):
guard let mention = MentionRef(block: m) else { return } // SPECIAL CASE:
str = str + mention_str(.any(mention), profiles: profiles) // We should keep hashtags at the end sequence but hide all the other previewables around it.
case .text(let txt): hide_text_index = index
if case .hashtag = blocks[safe: ind+1] { default:
// SPECIAL CASE: return .loopBreak
// Do not trim whitespaces from suffix if the following block is a hashtag. }
// This is because of the code further up (see "SPECIAL CASE"). }
str = str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: -1, txt: txt.as_str())) return .loopContinue
} else { })
str = str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt.as_str())) })
}
case .hashtag(let htag):
str = str + hashtag_str(htag.as_str())
case .invoice(let invoice):
guard let inv = invoice.as_invoice() else { return }
invoices.append(inv)
case .url(let url):
guard let url = URL(string: url.as_str()) else { return }
let url_type = classify_url(url)
switch url_type {
case .media:
urls.append(url_type)
case .link(let url):
urls.append(url_type)
str = str + url_str(url)
}
case .mention_index:
return
}
} }
return NoteArtifactsSeparated(content: txt, words: blocks.words, urls: urls, invoices: invoices) 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 { func reduce_text_block(ind: Int, hide_text_index: Int, txt: String) -> String {

View File

@@ -353,11 +353,11 @@ struct NoteContentView: View {
guard let blockGroup = try? NdbBlockGroup.from(event: event, using: damus_state.ndb, and: damus_state.keypair) else { guard let blockGroup = try? NdbBlockGroup.from(event: event, using: damus_state.ndb, and: damus_state.keypair) else {
return return
} }
for block in blockGroup.blocks { let _: Int? = try? blockGroup.forEachBlock { index, block in
switch block { switch block {
case .mention(let m): case .mention(let m):
guard let typ = m.bech32_type else { guard let typ = m.bech32_type else {
continue return .loopContinue
} }
switch typ { switch typ {
case .nprofile: case .nprofile:
@@ -368,18 +368,19 @@ struct NoteContentView: View {
if m.bech32.npub.matches_pubkey(pk: profile.pubkey) { if m.bech32.npub.matches_pubkey(pk: profile.pubkey) {
load(force_artifacts: true) load(force_artifacts: true)
} }
case .nevent: continue case .nevent: return .loopContinue
case .nrelay: continue case .nrelay: return .loopContinue
case .nsec: continue case .nsec: return .loopContinue
case .note: continue case .note: return .loopContinue
case .naddr: continue case .naddr: return .loopContinue
} }
case .text: return case .text: return .loopContinue
case .hashtag: return case .hashtag: return .loopContinue
case .url: return case .url: return .loopContinue
case .invoice: return case .invoice: return .loopContinue
case .mention_index(_): return case .mention_index(_): return .loopContinue
} }
return .loopContinue
} }
} }
.onAppear { .onAppear {
@@ -538,16 +539,21 @@ func separate_images(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [MediaUrl]?
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else { guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else {
return nil return nil
} }
let urlBlocks: [URL] = blockGroup.blocks.reduce(into: []) { urls, block in let urlBlocks: [URL] = (try? blockGroup.reduce(initialResult: Array<URL>()) { index, urls, block in
guard case .url(let url) = block, switch block {
let parsed_url = URL(string: url.as_str()) else { case .url(let url):
return guard let parsed_url = URL(string: url.as_str()) else {
return .loopContinue
}
if classify_url(parsed_url).is_img != nil {
return .loopReturn(urls + [parsed_url])
}
default:
break
} }
return .loopContinue
if classify_url(parsed_url).is_img != nil { }) ?? []
urls.append(parsed_url)
}
}
let mediaUrls = urlBlocks.map { MediaUrl.image($0) } let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
return mediaUrls.isEmpty ? nil : mediaUrls return mediaUrls.isEmpty ? nil : mediaUrls
} }

View File

@@ -74,13 +74,21 @@ func generate_local_notification_object(ndb: Ndb, from ev: NostrEvent, state: He
state.settings.mention_notification, state.settings.mention_notification,
let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: state.keypair) let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: state.keypair)
{ {
for case .mention(let mention) in blockGroup.blocks { let notification: LocalNotification? = try? blockGroup.forEachBlock({ index, block in
guard case .npub = mention.bech32_type, switch block {
(memcmp(state.keypair.pubkey.id.bytes, mention.bech32.npub.pubkey, 32) == 0) else { case .mention(let mention):
continue guard case .npub = mention.bech32_type,
(memcmp(state.keypair.pubkey.id.bytes, mention.bech32.npub.pubkey, 32) == 0) else {
return .loopContinue
}
let content_preview = render_notification_content_preview(ndb: ndb, ev: ev, profiles: state.profiles, keypair: state.keypair)
return .loopReturn(LocalNotification(type: .mention, event: ev, target: .note(ev), content: content_preview))
default:
return .loopContinue
} }
let content_preview = render_notification_content_preview(ndb: ndb, ev: ev, profiles: state.profiles, keypair: state.keypair) })
return LocalNotification(type: .mention, event: ev, target: .note(ev), content: content_preview) if let notification {
return notification
} }
if ev.referenced_ids.contains(where: { note_id in if ev.referenced_ids.contains(where: { note_id in

View File

@@ -348,12 +348,14 @@ class NoteContentViewTests: XCTestCase {
let kp = test_keypair_full let kp = test_keypair_full
let dm: NdbNote = NIP04.create_dm("Test", to_pk: kp.pubkey, tags: [], keypair: kp.to_keypair())! let dm: NdbNote = NIP04.create_dm("Test", to_pk: kp.pubkey, tags: [], keypair: kp.to_keypair())!
let blocks = try! NdbBlockGroup.from(event: dm, using: test_damus_state.ndb, and: kp.to_keypair()) let blocks = try! NdbBlockGroup.from(event: dm, using: test_damus_state.ndb, and: kp.to_keypair())
XCTAssertEqual(blocks.blocks.count, 1) let blockCount1 = try? blocks.withList({ $0.count })
XCTAssertEqual(blockCount1, 1)
let post = NostrPost(content: "Test", kind: .text) let post = NostrPost(content: "Test", kind: .text)
let event = post.to_event(keypair: kp)! let event = post.to_event(keypair: kp)!
let blocks2 = try! NdbBlockGroup.from(event: event, using: test_damus_state.ndb, and: kp.to_keypair()) let blocks2 = try! NdbBlockGroup.from(event: event, using: test_damus_state.ndb, and: kp.to_keypair())
XCTAssertEqual(blocks2.blocks.count, 1) let blockCount2 = try? blocks2.withList({ $0.count })
XCTAssertEqual(blockCount2, 1)
} }
func testMentionStr_Pubkey_ContainsAbbreviated() throws { func testMentionStr_Pubkey_ContainsAbbreviated() throws {

View File

@@ -51,7 +51,7 @@ extension ndb_invoice_block {
} }
} }
enum NdbBlock { enum NdbBlock: ~Copyable {
case text(ndb_str_block) case text(ndb_str_block)
case mention(ndb_mention_bech32_block) case mention(ndb_mention_bech32_block)
case hashtag(ndb_str_block) case hashtag(ndb_str_block)
@@ -107,10 +107,6 @@ struct NdbBlockGroup: ~Copyable {
fileprivate let metadata: MaybeTxn<BlocksMetadata> fileprivate let metadata: MaybeTxn<BlocksMetadata>
/// The raw text content of the note /// The raw text content of the note
fileprivate let rawTextContent: String fileprivate let rawTextContent: String
/// An iterable list of blocks that make up this object
var blocks: [NdbBlock] {
return self.collectBlocks()
}
var words: Int { var words: Int {
return metadata.borrow { $0.words } return metadata.borrow { $0.words }
} }
@@ -149,12 +145,12 @@ enum MaybeTxn<T: ~Copyable>: ~Copyable {
case pure(T) case pure(T)
case txn(SafeNdbTxn<T>) case txn(SafeNdbTxn<T>)
func borrow<Y>(_ borrowFunction: (borrowing T) -> Y) -> Y { func borrow<Y>(_ borrowFunction: (borrowing T) throws -> Y) rethrows -> Y {
switch self { switch self {
case .pure(let item): case .pure(let item):
return borrowFunction(item) return try borrowFunction(item)
case .txn(let txn): case .txn(let txn):
return borrowFunction(txn.val) return try borrowFunction(txn.val)
} }
} }
} }
@@ -239,35 +235,60 @@ extension NdbBlockGroup {
} }
} }
// MARK: - Enumeration support
extension NdbBlockGroup { extension NdbBlockGroup {
/// Collects all blocks in the group into an array without using Iterator/Sequence protocols typealias NdbBlockList = NonCopyableLinkedList<NdbBlock>
/// Borrows all blocks in the group one by one and runs a function defined by the caller.
/// ///
/// **Implementation note:** /// **Implementation note:**
/// This is done as a function instead of using `Sequence` and `Iterator` protocols because it does seem to be possible to conform to both `Sequence` and `~Copyable` at the same time. /// This is done as a function instead of using `Sequence` and `Iterator` protocols because it is currently not possible to conform to both `Sequence` and `~Copyable` at the same time, as Sequence requires elements to be `Copyable`
/// ///
/// - Returns: An array of all blocks in the group /// - Parameter borrowingFunction: The function to be run on each iteration. Takes in two parameters: The index of the item in the list (zero-indexed), and the block itself.
fileprivate func collectBlocks() -> [NdbBlock] { /// - Returns: The `Y` value returned by the provided function, when such function returns `.loopReturn(Y)`
var blocks = [NdbBlock]() @discardableResult
func forEachBlock<Y>(_ borrowingFunction: ((Int, borrowing NdbBlock) throws -> NdbBlockList.LoopCommand<Y>)) rethrows -> Y? {
return try withList({ try $0.forEachItem(borrowingFunction) })
}
/// Borrows all blocks in the group one by one and runs a function defined by the caller, in reverse order
///
/// **Implementation note:**
/// This is done as a function instead of using `Sequence` and `Iterator` protocols because it is currently not possible to conform to both `Sequence` and `~Copyable` at the same time, as Sequence requires elements to be `Copyable`
///
/// - Parameter borrowingFunction: The function to be run on each iteration. Takes in two parameters: The index of the item in the list (zero-indexed), and the block itself.
/// - Returns: The `Y` value returned by the provided function, when such function returns `.loopReturn(Y)`
@discardableResult
func forEachBlockReversed<Y>(_ borrowingFunction: ((Int, borrowing NdbBlock) throws -> NdbBlockList.LoopCommand<Y>)) rethrows -> Y? {
return try withList({ try $0.forEachItemReversed(borrowingFunction) })
}
/// Iterates over each item of the list, updating a final value, and returns the final result at the end.
func reduce<Y>(initialResult: Y, _ borrowingFunction: ((_ index: Int, _ partialResult: Y, _ item: borrowing NdbBlock) throws -> NdbBlockList.LoopCommand<Y>)) rethrows -> Y? {
return try withList({ try $0.reduce(initialResult: initialResult, borrowingFunction) })
}
/// Borrows the block list for processing
func withList<Y>(_ borrowingFunction: (borrowing NdbBlockList) throws -> Y) rethrows -> Y {
var linkedList: NdbBlockList = .init()
// Ensure the C string remains valid for the entire operation by keeping return try self.rawTextContent.withCString { cptr in
// all operations using it within the withCString closure
self.rawTextContent.withCString { cptr in
var iter = ndb_block_iterator(content: cptr, blocks: nil, block: ndb_block(), p: nil) var iter = ndb_block_iterator(content: cptr, blocks: nil, block: ndb_block(), p: nil)
// Start the iteration // Start the iteration
self.metadata.borrow { value in return try self.metadata.borrow { value in
ndb_blocks_iterate_start(cptr, value.as_ptr(), &iter) ndb_blocks_iterate_start(cptr, value.as_ptr(), &iter)
// Collect blocks into array // Collect blocks into array
while let ptr = ndb_blocks_iterate_next(&iter), outerLoop: while let ptr = ndb_blocks_iterate_next(&iter),
let block = NdbBlock(ndb_block_ptr(ptr: ptr)) { let block = NdbBlock(ndb_block_ptr(ptr: ptr)) {
blocks.append(block) linkedList.add(item: block)
} }
return try borrowingFunction(linkedList)
} }
} }
return blocks
} }
} }

View File

@@ -0,0 +1,160 @@
//
// NonCopyableLinkedList.swift
// damus
//
// Created by Daniel DAquino on 2025-07-04.
//
/// A linked list to help with iteration of non-copyable elements
///
/// This is needed to provide an array-like abstraction or iterators since swift arrays or iterator protocols require the element to be "copyable"
struct NonCopyableLinkedList<T: ~Copyable>: ~Copyable {
private var head: Node<T>? = nil
private var tail: Node<T>? = nil
private(set) var count: Int = 0
/// Iterates over each item of the list, with enumeration support.
func forEachItem<Y>(_ borrowingFunction: ((_ index: Int, _ item: borrowing T) throws -> LoopCommand<Y>)) rethrows -> Y? {
var indexCounter = 0
var cursor: Node? = self.head
outerLoop: while let nextItem = cursor {
let loopIterationResult = try borrowingFunction(indexCounter, nextItem.value)
indexCounter += 1
cursor = nextItem.next
switch loopIterationResult {
case .loopBreak:
break outerLoop
case .loopContinue:
continue outerLoop
case .loopReturn(let result):
return result
}
}
return nil
}
/// Iterates over each item of the list in reverse, with enumeration support.
func forEachItemReversed<Y, E: Error>(_ borrowingFunction: ((_ index: Int, _ item: borrowing T) throws(E) -> LoopCommand<Y>)) throws(E) -> Y? {
var indexCounter = count
var cursor: Node? = self.tail
outerLoop: while let nextItem = cursor {
let loopIterationResult = try borrowingFunction(indexCounter, nextItem.value)
indexCounter -= 1
cursor = nextItem.previous
switch loopIterationResult {
case .loopBreak:
break outerLoop
case .loopContinue:
continue outerLoop
case .loopReturn(let result):
return result
}
}
return nil
}
/// Iterates over each item of the list, with enumeration support, updating some value in each iteration and returning the final value at the end.
func reduce<Y>(initialResult: Y, _ borrowingFunction: ((_ index: Int, _ partialResult: Y, _ item: borrowing T) throws -> LoopCommand<Y>)) throws -> Y {
var indexCounter = 0
var currentResult = initialResult
var cursor: Node? = self.head
outerLoop: while let nextItem = cursor {
let loopIterationResult = try borrowingFunction(indexCounter, currentResult, nextItem.value)
indexCounter += 1
cursor = nextItem.next
switch loopIterationResult {
case .loopBreak:
break outerLoop
case .loopContinue:
continue outerLoop
case .loopReturn(let result):
currentResult = result
continue outerLoop
}
}
return currentResult
}
/// Uses a specific item of the list based on a provided index.
///
/// O(N/2) worst case scenario
///
/// Returns `nil` if nothing was found
func useItem<Y>(at index: Int, _ borrowingFunction: ((_ item: borrowing T) throws -> Y)) rethrows -> Y? {
if index < 0 || index >= self.count {
return nil
}
else if index < self.count / 2 {
return try self.forEachItem({ i, item in
if i == index {
return .loopReturn(try borrowingFunction(item))
}
return .loopContinue
})
}
else {
return try self.forEachItemReversed({ i, item in
if i == index {
return .loopReturn(try borrowingFunction(item))
}
return .loopContinue
})
}
}
/// Adds an item to the tail end list
mutating func add(item: consuming T) {
guard self.head != nil, let currentTail = self.tail else {
let firstNode = Node(value: item, next: nil, previous: nil)
self.head = firstNode
self.tail = firstNode
self.count = 1
return
}
let newTail = Node(value: item, next: nil, previous: currentTail)
currentTail.next = newTail
self.tail = newTail
self.count += 1
}
/// A node of the linked list
///
/// Should be `~Copyable` but that would require using a value type such as a struct or enum, and the Swift compiler does not support recursive enums with non-copyable objects for some reason. Example:
/// ```swift
/// enum List<Y: ~Copyable>: ~Copyable {
/// indirect case node(value: Y, next: NewList<Y>) // <-- ERROR: Noncopyable enum 'List' cannot be marked indirect or have indirect cases yet
/// case empty
/// }
/// ```
///
/// Therefore, we make it `private` to make sure we contain the exposure of this unsafe object to only this class. Outside users of the linked list can access objects via the iterator functions.
private class Node<Item: ~Copyable> {
let value: Item
var next: Node?
var previous: Node?
init(value: consuming Item, next: consuming Node?, previous: consuming Node?) {
self.value = value
self.next = next
self.previous = previous
}
}
/// A loop command to allow closures to control the loop they are in.
enum LoopCommand<Y> {
/// Breaks out of the loop
case loopBreak
/// Continues to the next iteration of the loop
case loopContinue
/// Stops iterating and return a value
case loopReturn(Y)
}
}