commit e9f4cbe881d8111f083966f78559f83aba92d9b6
parent 91abd187d35230741e40c2195f7b1ef7231e869d
Author: Daniel D’Aquino <daniel@daquino.me>
Date: Fri, 4 Jul 2025 11:48:50 -0700
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>
Diffstat:
8 files changed, 441 insertions(+), 210 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj
@@ -1571,6 +1571,10 @@
D74EA0932D2E77B9002290DD /* 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 */; };
+ 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 */; };
D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.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>"; };
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>"; };
+ 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>"; };
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>"; };
@@ -3154,6 +3159,7 @@
4C9054862A6AEB4500811EEC /* nostrdb */ = {
isa = PBXGroup;
children = (
+ D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */,
D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */,
D7F5630F2DEE71BB008509DE /* NdbFilter.swift */,
D74DEC892DA0A19800E69FA6 /* Ndb+.swift */,
@@ -5764,6 +5770,7 @@
4C3BEFD6281D995700B3DE84 /* ActionBarModel.swift in Sources */,
4C7D09762A0AF19E00943473 /* FillAndStroke.swift in Sources */,
4CA927612A290E340098A105 /* EventShell.swift in Sources */,
+ D74EC8502E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */,
4C363AA428296DEE006E126D /* SearchModel.swift in Sources */,
4C8D00CC29DF92DF0036AF10 /* Hashtags.swift in Sources */,
D74EA0942D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */,
@@ -6268,6 +6275,7 @@
82D6FBB02CD99F7900C925F4 /* RelayConnection.swift in Sources */,
82D6FBB12CD99F7900C925F4 /* RelayLog.swift in Sources */,
82D6FBB22CD99F7900C925F4 /* Nostr.swift in Sources */,
+ D74EC8512E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */,
82D6FBB32CD99F7900C925F4 /* NostrFilter.swift in Sources */,
82D6FBB42CD99F7900C925F4 /* NostrResponse.swift in Sources */,
82D6FBB52CD99F7900C925F4 /* NostrEvent.swift in Sources */,
@@ -6570,6 +6578,7 @@
D73E5E622C6A97F4007EB227 /* BlurHashEncode.swift in Sources */,
5C09FD122DF283D700823661 /* FollowPackModel.swift in Sources */,
D73E5E632C6A97F4007EB227 /* BlurHashDecode.swift in Sources */,
+ D74EC8522E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */,
D73E5F952C6AA753007EB227 /* FullScreenCarouselView.swift in Sources */,
D76BE18E2E0CF3DA004AD0C6 /* Interests.swift in Sources */,
D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */,
@@ -7020,6 +7029,7 @@
4CBB6F792B7311AA000477A4 /* error.c in Sources */,
4CBB6F7A2B7311AA000477A4 /* bech32_util.c in Sources */,
4CBB6F712B731184000477A4 /* bolt11.c in Sources */,
+ D74EC84F2E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */,
4CBB6F702B731179000477A4 /* invoice.c in Sources */,
4CBB6F6F2B73116B000477A4 /* content_parser.c in Sources */,
4CBB6F6E2B731113000477A4 /* block.c in Sources */,
diff --git a/damus/Core/Nostr/NostrEvent.swift b/damus/Core/Nostr/NostrEvent.swift
@@ -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 {
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
- if let firstBlock = blocks.first,
- case .mention(let mention) = firstBlock {
- switch mention.bech32_type {
- case .note:
- let data = mention.bech32.note.event_id.as_data(size: 32)
- return .note(NoteId(data))
- case .nevent:
- let data = mention.bech32.nevent.event_id.as_data(size: 32)
- return .note(NoteId(data))
+ return try? blockGroup.forEachBlock({ index, block in
+ // Step 1: Filter
+ switch block {
+ case .mention(let mention):
+ switch mention.bech32_type {
+ case .note:
+ let data = mention.bech32.note.event_id.as_data(size: 32)
+ return .loopReturn(.note(NoteId(data)))
+ case .nevent:
+ let data = mention.bech32.nevent.event_id.as_data(size: 32)
+ return .loopReturn(.note(NoteId(data)))
+ default:
+ return .loopBreak
+ }
default:
- return nil
+ return .loopContinue
}
- }
- return nil
+ })
}
func separate_invoices(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [Invoice]? {
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else {
return nil
}
- let invoiceBlocks: [Invoice] = blockGroup.blocks.reduce(into: []) { invoices, block in
- guard case .invoice(let invoice) = block,
- let invoice = invoice.as_invoice()
- else {
- return
+ let invoiceBlocks: [Invoice] = (try? blockGroup.reduce(initialResult: [Invoice](), { index, invoices, block in
+ switch block {
+ case .invoice(let invoice):
+ if let invoice = invoice.as_invoice() {
+ return .loopReturn(invoices + [invoice])
+ }
+ default:
+ break
}
- invoices.append(invoice)
- }
+ return .loopContinue
+ })) ?? []
return invoiceBlocks.isEmpty ? nil : invoiceBlocks
}
diff --git a/damus/Features/Events/Models/NoteContent.swift b/damus/Features/Events/Models/NoteContent.swift
@@ -105,152 +105,183 @@ func render_blocks(blocks: borrowing NdbBlockGroup, profiles: Profiles, can_hide
var end_mention_count = 0
var end_url_count = 0
- let ndb_blocks = blocks.blocks
- let one_note_ref = ndb_blocks
- .filter({
- if case .mention(let mention) = $0,
- let typ = mention.bech32_type,
+ 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 true
+ return .loopReturn(partialResult + 1)
}
- return false
- })
- .count == 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 = ndb_blocks.endIndex
+ var hide_text_index: Int = 0
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
+ 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 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
+ // 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
}
- default:
- break
+ hide_text_index = index
}
- 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
- }
- }
+ 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 = 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 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
}
- 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 = ndb_blocks.endIndex
- }
+ 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 {
- if case .text(let txt_block) = block,
- let txt = NdbBlock.convertToStringCopy(from: txt_block),
- txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- if case .hashtag = ndb_blocks[safe: ind+1] {
- str = str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt))
+ // 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
}
- } else if case .hashtag(let htag) = block {
- str = str + hashtag_str(htag.as_str())
}
- return
}
- }
- switch block {
- case .mention(let m):
- if let typ = m.bech32_type, typ.is_notelike, one_note_ref {
- return
- }
- guard let mention = MentionRef(block: m) else { return }
- str = str + mention_str(.any(mention), profiles: profiles)
- case .text(let txt):
- if case .hashtag = blocks[safe: ind+1] {
- // 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").
- str = str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: -1, txt: txt.as_str()))
- } 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)
+ 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
}
- case .mention_index:
- return
- }
- }
+ return .loopContinue
+ })
+ })
- return NoteArtifactsSeparated(content: txt, words: blocks.words, urls: urls, invoices: invoices)
+ 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 {
diff --git a/damus/Features/Events/NoteContentView.swift b/damus/Features/Events/NoteContentView.swift
@@ -353,11 +353,11 @@ struct NoteContentView: View {
guard let blockGroup = try? NdbBlockGroup.from(event: event, using: damus_state.ndb, and: damus_state.keypair) else {
return
}
- for block in blockGroup.blocks {
+ let _: Int? = try? blockGroup.forEachBlock { index, block in
switch block {
case .mention(let m):
guard let typ = m.bech32_type else {
- continue
+ return .loopContinue
}
switch typ {
case .nprofile:
@@ -368,18 +368,19 @@ struct NoteContentView: View {
if m.bech32.npub.matches_pubkey(pk: profile.pubkey) {
load(force_artifacts: true)
}
- case .nevent: continue
- case .nrelay: continue
- case .nsec: continue
- case .note: continue
- case .naddr: continue
+ case .nevent: return .loopContinue
+ case .nrelay: return .loopContinue
+ case .nsec: return .loopContinue
+ case .note: return .loopContinue
+ case .naddr: return .loopContinue
}
- case .text: return
- case .hashtag: return
- case .url: return
- case .invoice: return
- case .mention_index(_): return
+ case .text: return .loopContinue
+ case .hashtag: return .loopContinue
+ case .url: return .loopContinue
+ case .invoice: return .loopContinue
+ case .mention_index(_): return .loopContinue
}
+ return .loopContinue
}
}
.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 {
return nil
}
- let urlBlocks: [URL] = blockGroup.blocks.reduce(into: []) { urls, block in
- guard case .url(let url) = block,
- let parsed_url = URL(string: url.as_str()) else {
- return
- }
-
- if classify_url(parsed_url).is_img != nil {
- urls.append(parsed_url)
+ let urlBlocks: [URL] = (try? blockGroup.reduce(initialResult: Array<URL>()) { index, urls, block in
+ switch block {
+ case .url(let url):
+ 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
+ }) ?? []
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
return mediaUrls.isEmpty ? nil : mediaUrls
}
diff --git a/damus/Features/Notifications/Models/NotificationsManager.swift b/damus/Features/Notifications/Models/NotificationsManager.swift
@@ -74,13 +74,21 @@ func generate_local_notification_object(ndb: Ndb, from ev: NostrEvent, state: He
state.settings.mention_notification,
let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: state.keypair)
{
- for case .mention(let mention) in blockGroup.blocks {
- guard case .npub = mention.bech32_type,
- (memcmp(state.keypair.pubkey.id.bytes, mention.bech32.npub.pubkey, 32) == 0) else {
- continue
+ let notification: LocalNotification? = try? blockGroup.forEachBlock({ index, block in
+ switch block {
+ case .mention(let mention):
+ 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
diff --git a/damusTests/NoteContentViewTests.swift b/damusTests/NoteContentViewTests.swift
@@ -348,12 +348,14 @@ class NoteContentViewTests: XCTestCase {
let kp = test_keypair_full
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())
- XCTAssertEqual(blocks.blocks.count, 1)
+ let blockCount1 = try? blocks.withList({ $0.count })
+ XCTAssertEqual(blockCount1, 1)
let post = NostrPost(content: "Test", kind: .text)
let event = post.to_event(keypair: kp)!
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 {
diff --git a/nostrdb/NdbBlock.swift b/nostrdb/NdbBlock.swift
@@ -51,7 +51,7 @@ extension ndb_invoice_block {
}
}
-enum NdbBlock {
+enum NdbBlock: ~Copyable {
case text(ndb_str_block)
case mention(ndb_mention_bech32_block)
case hashtag(ndb_str_block)
@@ -107,10 +107,6 @@ struct NdbBlockGroup: ~Copyable {
fileprivate let metadata: MaybeTxn<BlocksMetadata>
/// The raw text content of the note
fileprivate let rawTextContent: String
- /// An iterable list of blocks that make up this object
- var blocks: [NdbBlock] {
- return self.collectBlocks()
- }
var words: Int {
return metadata.borrow { $0.words }
}
@@ -149,12 +145,12 @@ enum MaybeTxn<T: ~Copyable>: ~Copyable {
case pure(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 {
case .pure(let item):
- return borrowFunction(item)
+ return try borrowFunction(item)
case .txn(let txn):
- return borrowFunction(txn.val)
+ return try borrowFunction(txn.val)
}
}
}
@@ -239,35 +235,60 @@ extension NdbBlockGroup {
}
}
+
+// MARK: - Enumeration support
+
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:**
+ /// 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 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 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
- fileprivate func collectBlocks() -> [NdbBlock] {
- var blocks = [NdbBlock]()
+ /// - 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
- // all operations using it within the withCString closure
- self.rawTextContent.withCString { cptr in
+ return try self.rawTextContent.withCString { cptr in
var iter = ndb_block_iterator(content: cptr, blocks: nil, block: ndb_block(), p: nil)
// Start the iteration
- self.metadata.borrow { value in
+ return try self.metadata.borrow { value in
ndb_blocks_iterate_start(cptr, value.as_ptr(), &iter)
// 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)) {
- blocks.append(block)
+ linkedList.add(item: block)
}
+
+ return try borrowingFunction(linkedList)
}
}
-
- return blocks
}
}
-
-
diff --git a/nostrdb/NonCopyableLinkedList.swift b/nostrdb/NonCopyableLinkedList.swift
@@ -0,0 +1,160 @@
+//
+// NonCopyableLinkedList.swift
+// damus
+//
+// Created by Daniel D’Aquino 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)
+ }
+}