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:
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 {