damus

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

commit 514a053dce3b86ebf53817a97ef5543127ffab45
parent 0b199a18b42cb9f3fdc0c00e3a6811a30417cb60
Author: William Casarin <jb55@jb55.com>
Date:   Thu,  9 May 2024 13:33:04 -0700

nip10: marker replies

This should drastically increase compatibility for damus replies in
other clients.

Also filter non-pubkey references when replying so we don't run into the
q-tag bug.

Changelog-Added: Added nip10 marker replies
Changelog-Fixed: Fixed issue where some replies were including the q tag
Fixes: https://github.com/damus-io/damus/issues/2239
Fixes: https://github.com/damus-io/damus/issues/2233
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 16+++++++++++++++-
Mdamus/Models/Mentions.swift | 3+--
Mdamus/Models/Post.swift | 4+---
Rdamus/Models/EventRef.swift -> damus/NIP10/EventRef.swift | 0
Adamus/NIP10/ThreadReply.swift | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/PostView.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
MdamusTests/NIP10Tests.swift | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MdamusTests/ReplyTests.swift | 16++++++++--------
MdamusTests/damusTests.swift | 4++--
Mnostrdb/NdbNote.swift | 7+++++--
10 files changed, 231 insertions(+), 28 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -175,6 +175,7 @@ 4C3EA67D28FFBBA300C48A62 /* InvoicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3EA67C28FFBBA200C48A62 /* InvoicesView.swift */; }; 4C3EA67F28FFC01D00C48A62 /* InvoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3EA67E28FFC01D00C48A62 /* InvoiceView.swift */; }; 4C42812C298C848200DBF26F /* TranslateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C42812B298C848200DBF26F /* TranslateView.swift */; }; + 4C45E5022BED4D000025A428 /* ThreadReply.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C45E5012BED4D000025A428 /* ThreadReply.swift */; }; 4C463CBF2B960B96008A8C36 /* PurpleBackdrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C463CBE2B960B96008A8C36 /* PurpleBackdrop.swift */; }; 4C4793012A993CDA00489948 /* mdb.c in Sources */ = {isa = PBXBuildFile; fileRef = 4C4793002A993B9A00489948 /* mdb.c */; settings = {COMPILER_FLAGS = "-w"; }; }; 4C4793042A993DC000489948 /* midl.c in Sources */ = {isa = PBXBuildFile; fileRef = 4C4793032A993DB900489948 /* midl.c */; settings = {COMPILER_FLAGS = "-w"; }; }; @@ -248,6 +249,7 @@ 4C8D1A6C29F1DFC200ACDF75 /* FriendIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8D1A6B29F1DFC200ACDF75 /* FriendIcon.swift */; }; 4C8D1A6F29F31E5000ACDF75 /* FriendsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8D1A6E29F31E5000ACDF75 /* FriendsButton.swift */; }; 4C8EC52529D1FA6C0085D9A8 /* DamusColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8EC52429D1FA6C0085D9A8 /* DamusColors.swift */; }; + 4C8FA7242BED58A900798A6A /* ThreadReply.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C45E5012BED4D000025A428 /* ThreadReply.swift */; }; 4C9054852A6AEAA000811EEC /* NdbTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9054842A6AEAA000811EEC /* NdbTests.swift */; }; 4C90BD18283A9EE5008EE7EF /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90BD17283A9EE5008EE7EF /* LoginView.swift */; }; 4C90BD1A283AA67F008EE7EF /* Bech32.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90BD19283AA67F008EE7EF /* Bech32.swift */; }; @@ -994,6 +996,7 @@ 4C3EA67C28FFBBA200C48A62 /* InvoicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvoicesView.swift; sourceTree = "<group>"; }; 4C3EA67E28FFC01D00C48A62 /* InvoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvoiceView.swift; sourceTree = "<group>"; }; 4C42812B298C848200DBF26F /* TranslateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslateView.swift; sourceTree = "<group>"; }; + 4C45E5012BED4D000025A428 /* ThreadReply.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReply.swift; sourceTree = "<group>"; }; 4C463CBE2B960B96008A8C36 /* PurpleBackdrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleBackdrop.swift; sourceTree = "<group>"; }; 4C478E242A9932C100489948 /* Ndb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ndb.swift; sourceTree = "<group>"; }; 4C478E262A99353500489948 /* threadpool.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = threadpool.h; sourceTree = "<group>"; }; @@ -1615,7 +1618,6 @@ 4C363A93282704FA006E126D /* Post.swift */, 4C363A952827096D006E126D /* PostBlock.swift */, 4C363A9928283854006E126D /* Reply.swift */, - 4C363A9B282838B9006E126D /* EventRef.swift */, 4C363AA328296DEE006E126D /* SearchModel.swift */, 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */, 4C3AC79A28306D7B00E1F516 /* Contacts.swift */, @@ -1788,6 +1790,15 @@ path = flatbuffers; sourceTree = "<group>"; }; + 4C45E5002BED4CE10025A428 /* NIP10 */ = { + isa = PBXGroup; + children = ( + 4C363A9B282838B9006E126D /* EventRef.swift */, + 4C45E5012BED4D000025A428 /* ThreadReply.swift */, + ); + path = NIP10; + sourceTree = "<group>"; + }; 4C478E2A2A9935D300489948 /* bindings */ = { isa = PBXGroup; children = ( @@ -2486,6 +2497,7 @@ 4CE6DEE527F7A08100C66700 /* damus */ = { isa = PBXGroup; children = ( + 4C45E5002BED4CE10025A428 /* NIP10 */, 4C1D4FB32A7967990024F453 /* build-git-hash.txt */, 4CA3529C2A76AE47003BB08B /* Notify */, 4CC14FEC2A73FC9A007AEB17 /* Types */, @@ -3222,6 +3234,7 @@ 4C86F7C62A76C51100EC0817 /* AttachedWalletNotify.swift in Sources */, 4CF0ABE12981A83900D66079 /* MutelistView.swift in Sources */, 4CB883A82975FC1800DC99E7 /* Zaps.swift in Sources */, + 4C45E5022BED4D000025A428 /* ThreadReply.swift in Sources */, D74AAFD42B155ECB006CF0F4 /* Zaps+.swift in Sources */, 4C75EFB128049D510006080F /* NostrResponse.swift in Sources */, 4C7D09592A05BEAD00943473 /* KeyboardVisible.swift in Sources */, @@ -3589,6 +3602,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4C8FA7242BED58A900798A6A /* ThreadReply.swift in Sources */, D798D21F2B0858D600234419 /* MigratedTypes.swift in Sources */, D7CE1B472B0BE719002EDAD4 /* NativeObject.swift in Sources */, D7CB5D552B11758A00AD4105 /* UnmuteThreadNotify.swift in Sources */, diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift @@ -292,9 +292,8 @@ func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags { } func post_to_event(post: NostrPost, keypair: FullKeypair) -> NostrEvent? { - let tags = post.references.map({ r in r.tag }) + post.tags let post_blocks = parse_post_blocks(content: post.content) - let post_tags = make_post_tags(post_blocks: post_blocks, tags: tags) + let post_tags = make_post_tags(post_blocks: post_blocks, tags: post.tags) let content = post_tags.blocks .map(\.asString) .joined(separator: "") diff --git a/damus/Models/Post.swift b/damus/Models/Post.swift @@ -10,12 +10,10 @@ import Foundation struct NostrPost { let kind: NostrKind let content: String - let references: [RefId] let tags: [[String]] - init(content: String, references: [RefId], kind: NostrKind = .text, tags: [[String]] = []) { + init(content: String, kind: NostrKind = .text, tags: [[String]] = []) { self.content = content - self.references = references self.kind = kind self.tags = tags } diff --git a/damus/Models/EventRef.swift b/damus/NIP10/EventRef.swift diff --git a/damus/NIP10/ThreadReply.swift b/damus/NIP10/ThreadReply.swift @@ -0,0 +1,58 @@ +// +// ThreadReply.swift +// damus +// +// Created by William Casarin on 2024-05-09. +// + +import Foundation + + +struct ThreadReply { + let root: NoteRef + let reply: NoteRef? + let mention: Mention<NoteRef>? + + var is_reply_to_root: Bool { + guard let reply else { + // if we have no reply and only root then this is reply-to-root, + // but it should never really be in this form... + return true + } + + return root.id == reply.id + } + + init(root: NoteRef, reply: NoteRef?, mention: Mention<NoteRef>?) { + self.root = root + self.reply = reply + self.mention = mention + } + + init?(event_refs: [EventRef]) { + var root: NoteRef? = nil + var reply: NoteRef? = nil + var mention: Mention<NoteRef>? = nil + + for evref in event_refs { + switch evref { + case .mention(let m): + mention = m + case .thread_id(let r): + root = r + case .reply(let r): + reply = r + case .reply_to_root(let r): + root = r + reply = r + } + } + + // nip10 threads must have a root + guard let root else { + return nil + } + + self = ThreadReply(root: root, reply: reply, mention: mention) + } +} diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift @@ -92,13 +92,24 @@ struct PostView: View { } func send_post() { - let refs = references.filter { ref in - if case .pubkey(let pk) = ref, filtered_pubkeys.contains(pk) { - return false + // don't add duplicate pubkeys but retain order + var pkset = Set<Pubkey>() + + // we only want pubkeys really + let pks = references.reduce(into: Array<Pubkey>()) { acc, ref in + guard case .pubkey(let pk) = ref else { + return + } + + if pkset.contains(pk) || filtered_pubkeys.contains(pk) { + return } - return true + + pkset.insert(pk) + acc.append(pk) } - let new_post = build_post(state: damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: refs) + + let new_post = build_post(state: damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, pubkeys: pks) notify(.post(.post(new_post))) @@ -604,7 +615,29 @@ private func isAlphanumeric(_ char: Character) -> Bool { return char.isLetter || char.isNumber } -func build_post(state: DamusState, post: NSMutableAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId]) -> NostrPost { +func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair) -> [[String]] { + guard let nip10 = replying_to.thread_reply(keypair) else { + // we're replying to a post that isn't in a thread, + // just add a single reply-to-root tag + return [["e", replying_to.id.hex(), "", "root"]] + } + + // otherwise use the root tag from the parent's nip10 reply and include the note + // that we are replying to's note id. + var tags = [ + ["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"], + ["e", replying_to.id.hex(), "", "reply"] + ] + + // we also add the parent's nip10 reply tag as an additional e tag for context + if let reply = nip10.reply { + tags.append(["e", reply.note_id.hex(), reply.relay ?? ""]) + } + + return tags +} + +func build_post(state: DamusState, post: NSMutableAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost { post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in if let link = attributes[.link] as? String { let nextCharIndex = range.upperBound @@ -634,20 +667,35 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post let imagesString = uploadedMedias.map { $0.uploadedURL.absoluteString }.joined(separator: " ") - var tags = uploadedMedias.compactMap { $0.metadata?.to_tag() } - if !imagesString.isEmpty { content.append(" " + imagesString + " ") } - if case .quoting(let ev) = action { + var tags: [[String]] = [] + + switch action { + case .replying_to(let replying_to): + // start off with the reply tags + tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair) + + case .quoting(let ev): content.append(" nostr:" + bech32_note_id(ev.id)) if let quoted_ev = state.events.lookup(ev.id) { tags.append(["p", quoted_ev.pubkey.hex()]) } + case .posting(let postTarget): + break } + + // include pubkeys + tags += pubkeys.map { pk in + ["p", pk.hex()] + } + + // append additional tags + tags += uploadedMedias.compactMap { $0.metadata?.to_tag() } - return NostrPost(content: content, references: references, kind: .text, tags: tags) + return NostrPost(content: content, kind: .text, tags: tags) } diff --git a/damusTests/NIP10Tests.swift b/damusTests/NIP10Tests.swift @@ -127,9 +127,92 @@ final class NIP10Tests: XCTestCase { XCTAssertEqual(refs.reduce(into: Array<NoteId>(), { xs, r in if let note_id = r.is_reply?.note_id { xs.append(note_id) } }), [root_note_id]) + + let nip10 = note.thread_reply(test_keypair)! + XCTAssertEqual(nip10.is_reply_to_root, true) + XCTAssertEqual(nip10.root.note_id, root_note_id) + XCTAssertEqual(nip10.reply!.note_id, root_note_id) + } + + // seen in the wild by the gleasonator + func test_single_marker() { + let root_note_id_hex = "7c7d37bc8c04d2ec65cbc7d9275253e6b5cc34b5d10439f158194a3feefa8d52" + let tags = [ + ["e", root_note_id_hex, "", "reply"], + ] + + let root_note_id = NoteId(hex: root_note_id_hex)! + let note = NdbNote(content: "hi", keypair: test_keypair, kind: 1, tags: tags)! + let refs = interp_event_refs_without_mentions_ndb(note.referenced_noterefs) + let thread_reply = ThreadReply(event_refs: refs)! + + XCTAssertEqual(refs.reduce(into: Array<NoteId>(), { xs, r in + if let note_id = r.is_thread_id?.note_id { xs.append(note_id) } + }), [root_note_id]) + + XCTAssertEqual(refs.reduce(into: Array<NoteId>(), { xs, r in + if let note_id = r.is_direct_reply?.note_id { xs.append(note_id) } + }), [root_note_id]) + + XCTAssertEqual(refs.reduce(into: Array<NoteId>(), { xs, r in + if let note_id = r.is_reply?.note_id { xs.append(note_id) } + }), [root_note_id]) + + XCTAssertEqual(thread_reply.mention, nil) + XCTAssertEqual(thread_reply.root.note_id, root_note_id) + XCTAssertEqual(thread_reply.reply!.note_id, root_note_id) + XCTAssertEqual(thread_reply.is_reply_to_root, true) + } + + func test_marker_reply() { + let note_json = """ + { + "pubkey": "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e", + "content": "Can’t zap you btw", + "id": "a8dc8b74852d7ad114d5d650b2125459c0cba3c1fdcaaf527e03f24082e11ab3", + "created_at": 1715275773, + "sig": "4ee5d8f954c6c087ce51ad02d30dd226eea939cd9ef4e8a8ce4bfaf3aba0a852316cfda83ce3fc9a3d98392a738e7c6b036a3b2aced1392db1be3ca190835a17", + "kind": 1, + "tags": [ + [ + "e", + "1bb940ce0ba0d4a3b2a589355d908498dcd7452f941cf520072218f7e6ede75e", + "wss://relay.nostrplebs.com", + "reply" + ], + [ + "p", + "6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e" + ], + [ + "e", + "00152d2945459fb394fed2ea95af879c903c4ec42d96327a739fa27c023f20e0", + "wss://nostr.mutinywallet.com/", + "root" + ] + ] + } + """; + + let replying_to_hex = "a8dc8b74852d7ad114d5d650b2125459c0cba3c1fdcaaf527e03f24082e11ab3" + let pk = Pubkey(hex: "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e")! + let last_reply_hex = "1bb940ce0ba0d4a3b2a589355d908498dcd7452f941cf520072218f7e6ede75e" + let note = decode_nostr_event_json(json: note_json)! + let reply = build_post(state: test_damus_state, post: .init(string: "hello"), action: .replying_to(note), uploadedMedias: [], pubkeys: [pk] + note.referenced_pubkeys.map({pk in pk})) + let root_hex = "00152d2945459fb394fed2ea95af879c903c4ec42d96327a739fa27c023f20e0" + + XCTAssertEqual(reply.tags, + [ + ["e", root_hex, "wss://nostr.mutinywallet.com/", "root"], + ["e", replying_to_hex, "", "reply"], + ["e", last_reply_hex, "wss://relay.nostrplebs.com"], + ["p", "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e"], + ["p", "6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e"], + ]) } func test_mixed_nip10() { + let root_note_id_hex = "27e71cf53299dafb5dc7bcc0a078357418a4375cb1097bf5184662493f79a627" let reply_hex = "1a616998552cf76e9786f76ac68f6104cdae46377330735c68bfe0b9426d2fa8" diff --git a/damusTests/ReplyTests.swift b/damusTests/ReplyTests.swift @@ -123,7 +123,7 @@ class ReplyTests: XCTestCase { post.append(user_tag_attr_string(profile: profile, pubkey: pk)) post.append(.init(string: "\n")) - let post_note = build_post(state: test_damus_state, post: post, action: .posting(.none), uploadedMedias: [], references: [.pubkey(pk)]) + let post_note = build_post(state: test_damus_state, post: post, action: .posting(.none), uploadedMedias: [], pubkeys: [pk]) let expected_render = "nostr:\(pk.npub)\nnostr:\(pk.npub)" XCTAssertEqual(post_note.content, expected_render) @@ -315,7 +315,7 @@ class ReplyTests: XCTestCase { let pk = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")! let content = "this is a @\(pk.npub) mention" let blocks = parse_post_blocks(content: content) - let post = NostrPost(content: content, references: [.event(evid)]) + let post = NostrPost(content: content, tags: [["e", evid.hex()]]) let ev = post_to_event(post: post, keypair: test_keypair_full)! XCTAssertEqual(ev.tags.count, 2) @@ -330,7 +330,7 @@ class ReplyTests: XCTestCase { let nsec = "nsec1jmzdz7d0ldqctdxwm5fzue277ttng2pk28n2u8wntc2r4a0w96ssnyukg7" let content = "this is a @\(nsec) mention" let blocks = parse_post_blocks(content: content) - let post = NostrPost(content: content, references: [.event(evid)]) + let post = NostrPost(content: content, tags: [["e", evid.hex()]]) let ev = post_to_event(post: post, keypair: test_keypair_full)! XCTAssertEqual(ev.tags.count, 2) @@ -344,13 +344,13 @@ class ReplyTests: XCTestCase { let thread_id = NoteId(hex: "a250fc93570c3e87f9c9b08d6b3ef7b8e05d346df8a52c69e30ffecdb178fb9e")! let reply_id = NoteId(hex: "9a180a10f16dac9566543ad1fc29616aab272b0cf123ab5d58843e16f4ef03a3")! - let refs: [RefId] = [ - .event(thread_id), - .event(reply_id), - .pubkey(pubkey) + let tags = [ + ["e", thread_id.hex()], + ["e", reply_id.hex()], + ["p", pubkey.hex()] ] - let post = NostrPost(content: "this is a (@\(pubkey.npub)) mention", references: refs) + let post = NostrPost(content: "this is a (@\(pubkey.npub)) mention", tags: tags) let ev = post_to_event(post: post, keypair: test_keypair_full)! XCTAssertEqual(ev.content, "this is a (nostr:\(pubkey.npub)) mention") diff --git a/damusTests/damusTests.swift b/damusTests/damusTests.swift @@ -192,7 +192,7 @@ class damusTests: XCTestCase { */ func testMakeHashtagPost() { - let post = NostrPost(content: "#damus some content #bitcoin derp #かっこいい wow", references: []) + let post = NostrPost(content: "#damus some content #bitcoin derp #かっこいい wow", tags: []) let ev = post_to_event(post: post, keypair: test_keypair_full)! XCTAssertEqual(ev.tags.count, 3) @@ -269,7 +269,7 @@ class damusTests: XCTestCase { } private func createEventFromContentString(_ content: String) -> NostrEvent { - let post = NostrPost(content: content, references: []) + let post = NostrPost(content: content, tags: []) guard let ev = post_to_event(post: post, keypair: test_keypair_full) else { XCTFail("Could not create event") return test_note diff --git a/nostrdb/NdbNote.swift b/nostrdb/NdbNote.swift @@ -341,8 +341,11 @@ extension NdbNote { } func event_refs(_ keypair: Keypair) -> [EventRef] { - let refs = interpret_event_refs_ndb(blocks: self.blocks(keypair).blocks, tags: self.tags) - return refs + return interpret_event_refs_ndb(blocks: self.blocks(keypair).blocks, tags: self.tags) + } + + func thread_reply(_ keypair: Keypair) -> ThreadReply? { + ThreadReply(event_refs: event_refs(keypair)) } func get_content(_ keypair: Keypair) -> String {