damus

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

commit caa7802bce2ff07a4bd579e586b94d0bd8f46b41
parent 9c47d2e0bde950038c4d581335d37134cdb573fe
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Fri, 27 Jun 2025 19:27:42 -0700

Fix broken DM rendering

Currently NostrDB does not seem to handle encryption/decryption of DMs.
Since NostrDB now controls the block parsing process and fetches note
contents directly from the database, we have to add a specific condition
that injects decrypted content directly to the ndb content parser.

This is done in conjunction with some minor refactoring to `NdbBlocks`
and associated structs, as in C those are separated between the content
string and the offsets for each block, but in Swift this is more
ergonomically represented as a standalone/self-containing object.

No changelog entry is added because the previously broken version was
never released to the public, and therefore this fix produces no
user-facing changes compared to the last released version.

Changelog-None
Closes: https://github.com/damus-io/damus/issues/3106
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 26++++++++------------------
Mdamus/Core/Nostr/NostrEvent.swift | 12+++++-------
Mdamus/Features/Events/Models/NoteContent.swift | 24+++++++++++++++---------
Mdamus/Features/Events/NoteContentView.swift | 10++++------
Mdamus/Features/Notifications/Models/NotificationsManager.swift | 4++--
MdamusTests/NoteContentViewTests.swift | 14++++++++++++++
Mnostrdb/Ndb.swift | 10+++++-----
Mnostrdb/NdbBlock.swift | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Dnostrdb/NdbBlocksIterator.swift | 59-----------------------------------------------------------
Mnostrdb/NdbNote.swift | 18++++++++++--------
Mnostrdb/NdbTxn.swift | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
11 files changed, 349 insertions(+), 128 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -154,9 +154,7 @@ 4C32B95F2A9AD44700DC3548 /* Enum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B94A2A9AD44700DC3548 /* Enum.swift */; }; 4C32B9602A9AD44700DC3548 /* Struct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B94B2A9AD44700DC3548 /* Struct.swift */; }; 4C36245B2D5E9B2F00DD066E /* NdbProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF47FFE2B631C0100F2B2C0 /* NdbProfile.swift */; }; - 4C36245C2D5E9B4400DD066E /* NdbBlocksIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF480562B633F2600F2B2C0 /* NdbBlocksIterator.swift */; }; 4C36245D2D5E9B4400DD066E /* NdbBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF480582B633F3800F2B2C0 /* NdbBlock.swift */; }; - 4C36245E2D5E9B5F00DD066E /* NdbBlocksIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF480562B633F2600F2B2C0 /* NdbBlocksIterator.swift */; }; 4C36245F2D5E9B5F00DD066E /* NdbBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF480582B633F3800F2B2C0 /* NdbBlock.swift */; }; 4C3624602D5E9EB800DD066E /* NdbProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF47FFE2B631C0100F2B2C0 /* NdbProfile.swift */; }; 4C3624612D5E9FFD00DD066E /* wasm.c in Sources */ = {isa = PBXBuildFile; fileRef = 4CF480532B631C4F00F2B2C0 /* wasm.c */; }; @@ -343,10 +341,8 @@ 4CB883B6297730E400DC99E7 /* LNUrls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883B5297730E400DC99E7 /* LNUrls.swift */; }; 4CB8FC232A41ABA800763C51 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB8FC222A41ABA500763C51 /* AboutView.swift */; }; 4CB9D4A72992D02B00A9A7E4 /* ProfileNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB9D4A62992D02B00A9A7E4 /* ProfileNameView.swift */; }; - 4CBB6F662B72B5DD000477A4 /* NdbBlocksIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF480562B633F2600F2B2C0 /* NdbBlocksIterator.swift */; }; 4CBB6F672B72B5E8000477A4 /* NdbBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF480582B633F3800F2B2C0 /* NdbBlock.swift */; }; 4CBB6F682B72B5F0000477A4 /* NdbProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF47FFE2B631C0100F2B2C0 /* NdbProfile.swift */; }; - 4CBB6F692B72C783000477A4 /* NdbBlocksIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF480562B633F2600F2B2C0 /* NdbBlocksIterator.swift */; }; 4CBB6F6A2B730EF1000477A4 /* nostrdb.c in Sources */ = {isa = PBXBuildFile; fileRef = 4CF47FDE2B631C0100F2B2C0 /* nostrdb.c */; }; 4CBB6F6E2B731113000477A4 /* block.c in Sources */ = {isa = PBXBuildFile; fileRef = 4CF47FDF2B631C0100F2B2C0 /* block.c */; }; 4CBB6F6F2B73116B000477A4 /* content_parser.c in Sources */ = {isa = PBXBuildFile; fileRef = 4CF47FF62B631C0100F2B2C0 /* content_parser.c */; }; @@ -1558,15 +1554,15 @@ D74AAFD22B155E78006CF0F4 /* WalletConnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09612A098D0E00943473 /* WalletConnect.swift */; }; D74AAFD42B155ECB006CF0F4 /* Zaps+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */; }; D74AAFD62B155F0C006CF0F4 /* WalletConnect+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */; }; - D74E64132DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; }; - D74E64142DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; }; - D74E64152DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; }; D74DEC8A2DA0A19B00E69FA6 /* Ndb+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74DEC892DA0A19800E69FA6 /* Ndb+.swift */; }; D74DEC8B2DA0A19B00E69FA6 /* Ndb+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74DEC892DA0A19800E69FA6 /* Ndb+.swift */; }; D74DEC8C2DA0A19B00E69FA6 /* Ndb+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74DEC892DA0A19800E69FA6 /* Ndb+.swift */; }; D74DEC8F2DA0C65F00E69FA6 /* Ndb+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74DEC892DA0A19800E69FA6 /* Ndb+.swift */; }; D74DEC902DA0C6B500E69FA6 /* NostrFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFAE28049D340006080F /* NostrFilter.swift */; }; D74DEC912DA0CA2400E69FA6 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; }; + D74E64132DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; }; + D74E64142DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; }; + D74E64152DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; }; D74EA08A2D2BF2A7002290DD /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D767066E2C8BB3CE00F09726 /* URLHandler.swift */; }; D74EA08E2D2E271E002290DD /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA08D2D2E271E002290DD /* ErrorView.swift */; }; D74EA08F2D2E271E002290DD /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA08D2D2E271E002290DD /* ErrorView.swift */; }; @@ -1782,13 +1778,13 @@ D7F360262CEBBD8B009D34DA /* PresentFullScreenItemNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */; }; D7F360272CEBBDC0009D34DA /* DamusVideoControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EFBA362CC322F300F45588 /* DamusVideoControlsView.swift */; }; D7F360292CEBBE34009D34DA /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D7F360282CEBBE34009D34DA /* CodeScanner */; }; - D7FA46E52DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */; }; - D7FA46E62DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */; }; - D7FA46E72DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */; }; D7F563102DEE71C0008509DE /* NdbFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F5630F2DEE71BB008509DE /* NdbFilter.swift */; }; D7F563112DEE71C0008509DE /* NdbFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F5630F2DEE71BB008509DE /* NdbFilter.swift */; }; D7F563122DEE71C0008509DE /* NdbFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F5630F2DEE71BB008509DE /* NdbFilter.swift */; }; D7F563132DEE71C0008509DE /* NdbFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F5630F2DEE71BB008509DE /* NdbFilter.swift */; }; + D7FA46E52DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */; }; + D7FA46E62DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */; }; + D7FA46E72DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */; }; D7FB10A72B0C371A00FA8D42 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2B10272A7B0F5C008AA43E /* Log.swift */; }; D7FB14222BE5970000398331 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7FB14212BE5970000398331 /* PrivacyInfo.xcprivacy */; }; D7FB14252BE5A9A800398331 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7FB14242BE5A9A800398331 /* PrivacyInfo.xcprivacy */; }; @@ -2484,7 +2480,6 @@ 4CF480372B631C0100F2B2C0 /* invoice.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = invoice.c; sourceTree = "<group>"; }; 4CF480532B631C4F00F2B2C0 /* wasm.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = wasm.c; sourceTree = "<group>"; }; 4CF480542B631C4F00F2B2C0 /* wasm.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = wasm.h; sourceTree = "<group>"; }; - 4CF480562B633F2600F2B2C0 /* NdbBlocksIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbBlocksIterator.swift; sourceTree = "<group>"; }; 4CF480582B633F3800F2B2C0 /* NdbBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbBlock.swift; sourceTree = "<group>"; }; 4CFD502E2A2DA45800A229DB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = "<group>"; }; 4CFF8F5829C9FD1E008DB934 /* DamusPurpleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleView.swift; sourceTree = "<group>"; }; @@ -2628,8 +2623,8 @@ D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapDataModel.swift; sourceTree = "<group>"; }; D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Zaps+.swift"; sourceTree = "<group>"; }; D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WalletConnect+.swift"; sourceTree = "<group>"; }; - D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HumanReadableErrors.swift; sourceTree = "<group>"; }; D74DEC892DA0A19800E69FA6 /* Ndb+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Ndb+.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>"; }; D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventView.swift; sourceTree = "<group>"; }; D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = "<group>"; }; @@ -2694,8 +2689,8 @@ D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExtension.swift; sourceTree = "<group>"; }; D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusUserDefaults.swift; sourceTree = "<group>"; }; D7EFBA362CC322F300F45588 /* DamusVideoControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusVideoControlsView.swift; sourceTree = "<group>"; }; - D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCacheMigrations.swift; sourceTree = "<group>"; }; D7F5630F2DEE71BB008509DE /* NdbFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbFilter.swift; sourceTree = "<group>"; }; + D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCacheMigrations.swift; sourceTree = "<group>"; }; D7FB14212BE5970000398331 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; D7FB14242BE5A9A800398331 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; D7FD12252BD345A700CF195B /* FirstAidSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstAidSettingsView.swift; sourceTree = "<group>"; }; @@ -3183,7 +3178,6 @@ 4C78EFDA2A707C67007E8197 /* secp256k1_extrakeys.h */, 4C78EFD92A707C4D007E8197 /* secp256k1.h */, D798D2272B085CDA00234419 /* NdbNote+.swift */, - 4CF480562B633F2600F2B2C0 /* NdbBlocksIterator.swift */, 4CF480582B633F3800F2B2C0 /* NdbBlock.swift */, ); path = nostrdb; @@ -5384,7 +5378,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4CBB6F692B72C783000477A4 /* NdbBlocksIterator.swift in Sources */, 4C3DCC762A9FE9EC0091E592 /* NdbTxn.swift in Sources */, 4CEF958D2A9CE650000F901B /* verifier.c in Sources */, D73BDB0E2D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */, @@ -5998,7 +5991,6 @@ 4C3624632D5EA01100DD066E /* block.c in Sources */, 4C3624622D5EA00300DD066E /* nostrdb.c in Sources */, 4C3624612D5E9FFD00DD066E /* wasm.c in Sources */, - 4C36245C2D5E9B4400DD066E /* NdbBlocksIterator.swift in Sources */, 4C36245D2D5E9B4400DD066E /* NdbBlock.swift in Sources */, 4C36245B2D5E9B2F00DD066E /* NdbProfile.swift in Sources */, D7F360262CEBBD8B009D34DA /* PresentFullScreenItemNotify.swift in Sources */, @@ -6508,7 +6500,6 @@ 4C3624742D5EA1D700DD066E /* wasm.c in Sources */, 4C3624732D5EA1BE00DD066E /* nostrdb.c in Sources */, 4C3624602D5E9EB800DD066E /* NdbProfile.swift in Sources */, - 4C36245E2D5E9B5F00DD066E /* NdbBlocksIterator.swift in Sources */, 4C36245F2D5E9B5F00DD066E /* NdbBlock.swift in Sources */, D73E5E202C6A97F4007EB227 /* AttachedWalletNotify.swift in Sources */, D73E5E212C6A97F4007EB227 /* DisplayTabBarNotify.swift in Sources */, @@ -7035,7 +7026,6 @@ 4CBB6F6A2B730EF1000477A4 /* nostrdb.c in Sources */, 4CBB6F682B72B5F0000477A4 /* NdbProfile.swift in Sources */, 4CBB6F672B72B5E8000477A4 /* NdbBlock.swift in Sources */, - 4CBB6F662B72B5DD000477A4 /* NdbBlocksIterator.swift in Sources */, D74DEC8F2DA0C65F00E69FA6 /* Ndb+.swift in Sources */, D798D21F2B0858D600234419 /* MigratedTypes.swift in Sources */, D7CE1B472B0BE719002EDAD4 /* NativeObject.swift in Sources */, diff --git a/damus/Core/Nostr/NostrEvent.swift b/damus/Core/Nostr/NostrEvent.swift @@ -780,12 +780,11 @@ func validate_event(ev: NostrEvent) -> ValidationResult { } func first_eref_mention(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> Mention<NoteId>? { - guard let blocks_txn = ev.blocks(ndb: ndb) else { + guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else { return nil } - let ndb_blocks = blocks_txn.unsafeUnownedValue - let blocks = ndb_blocks.iter(note: ev).filter { block in + let blocks = blockGroup.blocks.filter { block in guard case .mention(let mention) = block else { return false } @@ -815,12 +814,11 @@ func first_eref_mention(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> Mention<N return nil } -func separate_invoices(ndb: Ndb, ev: NostrEvent) -> [Invoice]? { - guard let blocks_txn = ev.blocks(ndb: ndb) else { +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 ndb_blocks = blocks_txn.unsafeUnownedValue - let invoiceBlocks: [Invoice] = ndb_blocks.iter(note: ev).reduce(into: []) { invoices, block in + let invoiceBlocks: [Invoice] = blockGroup.blocks.reduce(into: []) { invoices, block in guard case .invoice(let invoice) = block, let invoice = invoice.as_invoice() else { diff --git a/damus/Features/Events/Models/NoteContent.swift b/damus/Features/Events/Models/NoteContent.swift @@ -67,22 +67,28 @@ func note_artifact_is_separated(kind: NostrKind?) -> Bool { } func render_immediately_available_note_content(ndb: Ndb, ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> NoteArtifacts { - guard let blocks = ev.blocks(ndb: ndb) else { - return .separated(.just_content(ev.get_content(keypair))) - } - if ev.known_kind == .longform { return .longform(LongformContent(ev.content)) } - return .separated(render_blocks(blocks: blocks.unsafeUnownedValue, profiles: profiles, note: ev, can_hide_last_previewable_refs: true)) + do { + let blocks = try NdbBlockGroup.from(event: ev, using: ndb, and: keypair) + return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true)) + } + catch { + // TODO: Improve error handling in the future, bubbling it up so that the view can decide how display errors. Keep legacy behavior for now. + return .separated(.just_content(ev.get_content(keypair))) + } } actor ContentRenderer { func render_note_content(ndb: Ndb, ev: NostrEvent, profiles: Profiles, keypair: Keypair) async -> NoteArtifacts { - guard let result = try? await ndb.waitFor(noteId: ev.id, timeout: 10) else { - return .separated(.just_content(ev.get_content(keypair))) + if ev.known_kind == .dm { + // Use the enhanced render_immediately_available_note_content which now handles DMs properly + // by decrypting and parsing the content with ndb_parse_content + return render_immediately_available_note_content(ndb: ndb, ev: ev, profiles: profiles, keypair: keypair) } + let result = try? await ndb.waitFor(noteId: ev.id, timeout: 3) return render_immediately_available_note_content(ndb: ndb, ev: ev, profiles: profiles, keypair: keypair) } } @@ -92,14 +98,14 @@ actor ContentRenderer { // Block previews should actually be rendered in the position of the note content where it was found. // Currently, we put some previews at the bottom of the note, which is incorrect as they take things out of // the author's intended context. -func render_blocks(blocks: NdbBlocks, profiles: Profiles, note: NdbNote, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated { +func render_blocks(blocks: borrowing NdbBlockGroup, profiles: Profiles, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated { var invoices: [Invoice] = [] var urls: [UrlType] = [] var end_mention_count = 0 var end_url_count = 0 - let ndb_blocks = blocks.iter(note: note).collect() + let ndb_blocks = blocks.blocks let one_note_ref = ndb_blocks .filter({ if case .mention(let mention) = $0, diff --git a/damus/Features/Events/NoteContentView.swift b/damus/Features/Events/NoteContentView.swift @@ -350,11 +350,10 @@ struct NoteContentView: View { var body: some View { ArtifactContent .onReceive(handle_notify(.profile_updated)) { profile in - guard let blocks_txn = event.blocks(ndb: damus_state.ndb) else { + guard let blockGroup = try? NdbBlockGroup.from(event: event, using: damus_state.ndb, and: damus_state.keypair) else { return } - let blocks = blocks_txn.unsafeUnownedValue - for block in blocks.iter(note: event) { + for block in blockGroup.blocks { switch block { case .mention(let m): guard let typ = m.bech32_type else { @@ -536,11 +535,10 @@ struct NoteContentView_Previews: PreviewProvider { } func separate_images(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? { - guard let blocks_txn = ev.blocks(ndb: ndb) else { + guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else { return nil } - let blocks = blocks_txn.unsafeUnownedValue - let urlBlocks: [URL] = blocks.iter(note: ev).reduce(into: []) { urls, block in + 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 diff --git a/damus/Features/Notifications/Models/NotificationsManager.swift b/damus/Features/Notifications/Models/NotificationsManager.swift @@ -72,9 +72,9 @@ func generate_local_notification_object(ndb: Ndb, from ev: NostrEvent, state: He if type == .text, state.settings.mention_notification, - let blocks = ev.blocks(ndb: ndb)?.unsafeUnownedValue + let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: state.keypair) { - for case .mention(let mention) in blocks.iter(note: ev) { + 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 diff --git a/damusTests/NoteContentViewTests.swift b/damusTests/NoteContentViewTests.swift @@ -342,6 +342,20 @@ class NoteContentViewTests: XCTestCase { XCTAssertTrue((parsed.blocks[0].asURL != nil), "NoteContentView does not correctly parse an image block when url in JSON content contains optional escaped slashes.") } + /// Quick test that exercises the direct parsing methods (i.e. not fetching blocks from nostrdb) from `NdbBlockGroup`, and its bridging code with C. + /// The parsing logic itself already has test coverage at the nostrdb level. + func testDirectBlockParsing() { + 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 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) + } + func testMentionStr_Pubkey_ContainsAbbreviated() throws { let compatibleText = createCompatibleText(test_pubkey.npub) diff --git a/nostrdb/Ndb.swift b/nostrdb/Ndb.swift @@ -227,16 +227,16 @@ class Ndb { return true } - func lookup_blocks_by_key_with_txn<Y>(_ key: NoteKey, txn: NdbTxn<Y>) -> NdbBlocks? { + func lookup_blocks_by_key_with_txn(_ key: NoteKey, txn: RawNdbTxnAccessible) -> NdbBlockGroup.BlocksMetadata? { guard let blocks = ndb_get_blocks_by_key(self.ndb.ndb, &txn.txn, key) else { return nil } - return NdbBlocks(ptr: blocks) + return NdbBlockGroup.BlocksMetadata(ptr: blocks) } - func lookup_blocks_by_key(_ key: NoteKey) -> NdbTxn<NdbBlocks?>? { - NdbTxn(ndb: self) { txn in + func lookup_blocks_by_key(_ key: NoteKey) -> SafeNdbTxn<NdbBlockGroup.BlocksMetadata?>? { + SafeNdbTxn<NdbBlockGroup.BlocksMetadata?>.new(on: self) { txn in lookup_blocks_by_key_with_txn(key, txn: txn) } } @@ -493,7 +493,7 @@ class Ndb { } } - func lookup_note_key_with_txn<Y>(_ id: NoteId, txn: NdbTxn<Y>) -> NoteKey? { + func lookup_note_key_with_txn(_ id: NoteId, txn: some RawNdbTxnAccessible) -> NoteKey? { guard !closed else { return nil } return id.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> NoteKey? in guard let p = ptr.baseAddress else { diff --git a/nostrdb/NdbBlock.swift b/nostrdb/NdbBlock.swift @@ -93,31 +93,180 @@ enum NdbBlock { guard let cString = block.str else { return nil } + // Copy byte-by-byte from the pointer into a new buffer let byteBuffer = UnsafeBufferPointer(start: cString, count: Int(block.len)).map { UInt8(bitPattern: $0) } - // Create a Swift String from the byte array + // Create an owned Swift String from the buffer we created return String(bytes: byteBuffer, encoding: .utf8) } } - -struct NdbBlocks { - private let blocks_ptr: ndb_blocks_ptr - - init(ptr: OpaquePointer?) { - self.blocks_ptr = ndb_blocks_ptr(ptr: ptr) +/// Represents a group of blocks +struct NdbBlockGroup: ~Copyable { + /// The block offsets + 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 { - Int(ndb_blocks_word_count(blocks_ptr.ptr)) + return metadata.borrow { $0.words } } + + /// Gets the parsed blocks from a specific note. + /// + /// This function will: + /// - fetch blocks information from NostrDB if possible _and_ available, or + /// - parse blocks on-demand. + static func from(event: NdbNote, using ndb: Ndb, and keypair: Keypair) throws(NdbBlocksError) -> Self { + if event.is_content_encrypted() { + return try parse(event: event, keypair: keypair) + } + else { + guard let offsets = event.block_offsets(ndb: ndb) else { + return try parse(event: event, keypair: keypair) + } + return .init(metadata: .txn(offsets), rawTextContent: event.content) + } + } + + /// Parses the note contents on-demand from a specific note. + /// + /// Prioritize using `from(event: NdbNote, using ndb: Ndb, and keypair: Keypair)` when possible. + static func parse(event: NdbNote, keypair: Keypair) throws(NdbBlocksError) -> Self { + guard let content = event.maybe_get_content(keypair) else { throw NdbBlocksError.decryptionError } + guard let metadata = BlocksMetadata.parseContent(content: content) else { throw NdbBlocksError.parseError } + return self.init( + metadata: .pure(metadata), + rawTextContent: content + ) + } +} - func iter(note: NdbNote) -> BlocksSequence { - BlocksSequence(note: note, blocks: self) +enum MaybeTxn<T: ~Copyable>: ~Copyable { + case pure(T) + case txn(SafeNdbTxn<T>) + + func borrow<Y>(_ borrowFunction: (borrowing T) -> Y) -> Y { + switch self { + case .pure(let item): + return borrowFunction(item) + case .txn(let txn): + return borrowFunction(txn.val) + } } +} + - func as_ptr() -> OpaquePointer? { - return self.blocks_ptr.ptr +// MARK: - Helper structs + +extension NdbBlockGroup { + /// Wrapper for the `ndb_blocks` C struct + /// + /// This does not store the actual block contents, only the offsets on the content string and block metadata. + /// + /// **Implementation note:** This would be better as `~Copyable`, but `NdbTxn` does not support `~Copyable` yet. + struct BlocksMetadata: ~Copyable { + private let blocks_ptr: ndb_blocks_ptr + private let buffer: UnsafeMutableRawPointer? + + init(ptr: OpaquePointer?, buffer: UnsafeMutableRawPointer? = nil) { + self.blocks_ptr = ndb_blocks_ptr(ptr: ptr) + self.buffer = buffer + } + + var words: Int { + Int(ndb_blocks_word_count(blocks_ptr.ptr)) + } + + /// Gets the opaque pointer + /// + /// **Implementation note:** This is marked `fileprivate` because we want to minimize the exposure of raw pointers to Swift code outside these wrapper structs. + fileprivate func as_ptr() -> OpaquePointer? { + return self.blocks_ptr.ptr + } + + /// Parses text content and returns the parsed block metadata if successful + /// + /// **Implementation notes:** This is `fileprivate` because it makes no sense for outside Swift code to use this directly. Use `NdbBlockGroup` instead. + fileprivate static func parseContent(content: String) -> Self? { + // Allocate scratch buffer with enough space + guard let buffer = malloc(MAX_NOTE_SIZE) else { + return nil + } + + var blocks: OpaquePointer? = nil + + // Call the C parsing function and check its success status + let success = content.withCString { contentPtr -> Bool in + let contentLen = content.utf8.count + return ndb_parse_content( + buffer.assumingMemoryBound(to: UInt8.self), + Int32(MAX_NOTE_SIZE), + contentPtr, + Int32(contentLen), + &blocks + ) == 1 + } + + if !success || blocks == nil { + // Something failed + free(buffer) + return nil + } + + // TODO: We should set the owned flag as in the C code. + // However, There does not seem to be a way to set this from Swift code. The code shown below does not work. + // blocks!.pointee.flags |= NDB_BLOCK_FLAG_OWNED + // But perhaps this is not necessary because `NdbBlockGroup` is non-copyable + + return BlocksMetadata(ptr: blocks, buffer: buffer) + } + + deinit { + if let buffer { + free(buffer) + } + } + } + + /// Models specific errors that may happen when parsing or constructing an `NdbBlocks` object + enum NdbBlocksError: Error { + case parseError + case decryptionError + } +} + +extension NdbBlockGroup { + /// Collects all blocks in the group into an array without using Iterator/Sequence protocols + /// + /// **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. + /// + /// - Returns: An array of all blocks in the group + fileprivate func collectBlocks() -> [NdbBlock] { + var blocks = [NdbBlock]() + + // 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 + var iter = ndb_block_iterator(content: cptr, blocks: nil, block: ndb_block(), p: nil) + + // Start the iteration + 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), + let block = NdbBlock(ndb_block_ptr(ptr: ptr)) { + blocks.append(block) + } + } + + return blocks } } diff --git a/nostrdb/NdbBlocksIterator.swift b/nostrdb/NdbBlocksIterator.swift @@ -1,59 +0,0 @@ -// -// NdbBlockIterator.swift -// damus -// -// Created by William Casarin on 2024-01-25. -// - -import Foundation - - -struct BlocksIterator: IteratorProtocol { - typealias Element = NdbBlock - - var done: Bool - var iter: ndb_block_iterator - var note: NdbNote - - mutating func next() -> NdbBlock? { - guard iter.blocks != nil, - let ptr = ndb_blocks_iterate_next(&iter) else { - done = true - return nil - } - - let block_ptr = ndb_block_ptr(ptr: ptr) - return NdbBlock(block_ptr) - } - - init(note: NdbNote, blocks: NdbBlocks) { - let content = ndb_note_content(note.note.ptr) - self.iter = ndb_block_iterator(content: content, blocks: nil, block: ndb_block(), p: nil) - ndb_blocks_iterate_start(content, blocks.as_ptr(), &self.iter) - self.done = false - self.note = note - } -} - -struct BlocksSequence: Sequence { - let blocks: NdbBlocks - let note: NdbNote - - init(note: NdbNote, blocks: NdbBlocks) { - self.blocks = blocks - self.note = note - } - - func makeIterator() -> BlocksIterator { - return .init(note: note, blocks: blocks) - } - - func collect() -> [NdbBlock] { - var xs = [NdbBlock]() - for x in self { - xs.append(x) - } - return xs - } -} - diff --git a/nostrdb/NdbNote.swift b/nostrdb/NdbNote.swift @@ -457,23 +457,25 @@ extension NdbNote { return ThreadReply(tags: self.tags)?.reply.note_id } - func blocks(ndb: Ndb) -> NdbTxn<NdbBlocks>? { - let blocks_txn = NdbTxn<NdbBlocks?>(ndb: ndb) { txn in + func block_offsets(ndb: Ndb) -> SafeNdbTxn<NdbBlockGroup.BlocksMetadata>? { + let blocks_txn: SafeNdbTxn<NdbBlockGroup.BlocksMetadata>? = .new(on: ndb) { txn -> NdbBlockGroup.BlocksMetadata? in guard let key = ndb.lookup_note_key_with_txn(self.id, txn: txn) else { return nil } return ndb.lookup_blocks_by_key_with_txn(key, txn: txn) } - guard let blocks_txn else { - return nil - } + guard let blocks_txn else { return nil } - return blocks_txn.collect() + return blocks_txn + } + + func is_content_encrypted() -> Bool { + return known_kind == .dm // Probably other kinds should be listed here } func get_content(_ keypair: Keypair) -> String { - if known_kind == .dm { + if is_content_encrypted() { return decrypted(keypair: keypair) ?? "*failed to decrypt content*" } else if known_kind == .highlight { @@ -484,7 +486,7 @@ extension NdbNote { } func maybe_get_content(_ keypair: Keypair) -> String? { - if known_kind == .dm { + if is_content_encrypted() { return decrypted(keypair: keypair) } diff --git a/nostrdb/NdbTxn.swift b/nostrdb/NdbTxn.swift @@ -12,7 +12,7 @@ fileprivate var txn_count: Int = 0 #endif // Would use struct and ~Copyable but generics aren't supported well -class NdbTxn<T> { +class NdbTxn<T>: RawNdbTxnAccessible { var txn: ndb_txn private var val: T! var moved: Bool @@ -117,6 +117,129 @@ class NdbTxn<T> { } } +protocol RawNdbTxnAccessible: AnyObject { + var txn: ndb_txn { get set } +} + +class PlaceholderNdbTxn: RawNdbTxnAccessible { + var txn: ndb_txn + + init(txn: ndb_txn) { + self.txn = txn + } +} + +class SafeNdbTxn<T: ~Copyable> { + var txn: ndb_txn + var val: T! + var moved: Bool + var inherited: Bool + var ndb: Ndb + var generation: Int + var name: String + + static func pure(ndb: Ndb, val: consuming T) -> SafeNdbTxn<T> { + .init(ndb: ndb, txn: ndb_txn(), val: val, generation: ndb.generation, inherited: true, name: "pure_txn") + } + + static func new(on ndb: Ndb, with valueGetter: (PlaceholderNdbTxn) -> T? = { _ in () }, name: String = "txn") -> SafeNdbTxn<T>? { + guard !ndb.is_closed else { return nil } + var generation = ndb.generation + var txn: ndb_txn + let inherited: Bool + if let active_txn = Thread.current.threadDictionary["ndb_txn"] as? ndb_txn { + // some parent thread is active, use that instead + print("txn: inherited txn") + txn = active_txn + inherited = true + generation = Thread.current.threadDictionary["txn_generation"] as! Int + } else { + txn = ndb_txn() + guard !ndb.is_closed else { return nil } + generation = ndb.generation + #if TXNDEBUG + txn_count += 1 + #endif + let ok = ndb_begin_query(ndb.ndb.ndb, &txn) != 0 + if !ok { + return nil + } + generation = ndb.generation + Thread.current.threadDictionary["ndb_txn"] = txn + Thread.current.threadDictionary["txn_generation"] = ndb.generation + inherited = false + } + #if TXNDEBUG + print("txn: open gen\(self.generation) '\(self.name)' \(txn_count)") + #endif + let moved = false + let placeholderTxn = PlaceholderNdbTxn(txn: txn) + guard let val = valueGetter(placeholderTxn) else { return nil } + return SafeNdbTxn<T>(ndb: ndb, txn: txn, val: val, generation: generation, inherited: inherited, name: name) + } + + private init(ndb: Ndb, txn: ndb_txn, val: consuming T, generation: Int, inherited: Bool, name: String) { + self.txn = txn + self.val = consume val + self.moved = false + self.inherited = inherited + self.ndb = ndb + self.generation = generation + self.name = name + } + + deinit { + if self.generation != ndb.generation { + print("txn: OLD GENERATION (\(self.generation) != \(ndb.generation)), IGNORING") + return + } + if inherited { + print("txn: not closing. inherited ") + return + } + if moved { + //print("txn: not closing. moved") + return + } + if ndb.is_closed { + print("txn: not closing. db closed") + return + } + + #if TXNDEBUG + txn_count -= 1; + print("txn: close gen\(generation) '\(name)' \(txn_count)") + #endif + ndb_end_query(&self.txn) + //self.skip_close = true + Thread.current.threadDictionary.removeObject(forKey: "ndb_txn") + } + + // functor + func map<Y>(_ transform: (borrowing T) -> Y) -> SafeNdbTxn<Y> { + self.moved = true + return .init(ndb: self.ndb, txn: self.txn, val: transform(val), generation: generation, inherited: inherited, name: self.name) + } + + // comonad!? + // useful for moving ownership of a transaction to another value + func extend<Y>(_ with: (SafeNdbTxn<T>) -> Y) -> SafeNdbTxn<Y> { + self.moved = true + return .init(ndb: self.ndb, txn: self.txn, val: with(self), generation: generation, inherited: inherited, name: self.name) + } + + consuming func maybeExtend<Y>(_ with: (consuming SafeNdbTxn<T>) -> Y?) -> SafeNdbTxn<Y>? where Y: ~Copyable { + self.moved = true + let ndb = self.ndb + let txn = self.txn + let generation = self.generation + let inherited = self.inherited + let name = self.name + guard let newVal = with(consume self) else { return nil } + return .init(ndb: ndb, txn: txn, val: newVal, generation: generation, inherited: inherited, name: name) + } +} + protocol OptionalType { associatedtype Wrapped var optional: Wrapped? { get }