damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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:
Mdamus.xcodeproj/project.pbxproj | 10++++++++++
Mdamus/Core/Nostr/NostrEvent.swift | 59++++++++++++++++++++++++++---------------------------------
Mdamus/Features/Events/Models/NoteContent.swift | 281++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mdamus/Features/Events/NoteContentView.swift | 48+++++++++++++++++++++++++++---------------------
Mdamus/Features/Notifications/Models/NotificationsManager.swift | 20++++++++++++++------
MdamusTests/NoteContentViewTests.swift | 6++++--
Mnostrdb/NdbBlock.swift | 67++++++++++++++++++++++++++++++++++++++++++++-----------------------
Anostrdb/NonCopyableLinkedList.swift | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } +}