damus

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

commit 793970beafb762ed74f99718f626a2ad5cd65a75
parent 049d9170bee1a21d3844dfe0d22986dffb4110f6
Author: Terry Yiu <git@tyiu.xyz>
Date:   Sun, 13 Jul 2025 01:45:16 -0400

Add relay hints to tags and identifiers

Changelog-Added: Add relay hints to tags and identifiers
Signed-off-by: Terry Yiu <git@tyiu.xyz>

Diffstat:
Mdamus/Models/Mentions.swift | 31++++++++++++++++++++++++++++---
Mdamus/Models/NostrNetworkManager/NostrNetworkManager.swift | 10++++++++++
Mdamus/Models/ProfileModel.swift | 4+---
Mdamus/Nostr/NostrEvent.swift | 30++++++++++++++++++++++++------
Mdamus/Nostr/RelayPool.swift | 19+++++--------------
Mdamus/Util/Bech32Object.swift | 4++++
Mdamus/Util/Constants.swift | 1+
Mdamus/Views/ActionBar/EventActionBar.swift | 17++++++++++++++---
Mdamus/Views/ActionBar/RepostAction.swift | 2+-
Mdamus/Views/ActionBar/ShareAction.swift | 13+++++++++++--
Mdamus/Views/Chat/ChatEventView.swift | 2+-
Mdamus/Views/Events/EventMenu.swift | 13+++++++++++--
Mdamus/Views/PostView.swift | 22+++++++++++++---------
MdamusTests/Bech32ObjectTests.swift | 18+++++++++++++++++-
MdamusTests/LikeTests.swift | 14+++++++-------
MdamusTests/PostViewTests.swift | 2+-
16 files changed, 149 insertions(+), 53 deletions(-)

diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift @@ -64,10 +64,35 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable { switch self { case .pubkey(let pubkey): return ["p", pubkey.hex()] case .note(let noteId): return ["e", noteId.hex()] - case .nevent(let nevent): return ["e", nevent.noteid.hex()] - case .nprofile(let nprofile): return ["p", nprofile.author.hex()] + case .nevent(let nevent): + var tagBuilder = ["e", nevent.noteid.hex()] + + let relay = nevent.relays.first + if let author = nevent.author?.hex() { + tagBuilder.append(relay ?? "") + tagBuilder.append(author) + } else if let relay { + tagBuilder.append(relay) + } + + return tagBuilder + case .nprofile(let nprofile): + var tagBuilder = ["p", nprofile.author.hex()] + + if let relay = nprofile.relays.first { + tagBuilder.append(relay) + } + + return tagBuilder case .nrelay(let url): return ["r", url] - case .naddr(let naddr): return ["a", naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier.string()] + case .naddr(let naddr): + var tagBuilder = ["a", "\(naddr.kind.description):\(naddr.author.hex()):\(naddr.identifier.string())"] + + if let relay = naddr.relays.first { + tagBuilder.append(relay) + } + + return tagBuilder } } diff --git a/damus/Models/NostrNetworkManager/NostrNetworkManager.swift b/damus/Models/NostrNetworkManager/NostrNetworkManager.swift @@ -51,6 +51,16 @@ class NostrNetworkManager { func connect() { self.userRelayList.connect() } + + func relaysForEvent(event: NostrEvent) -> [RelayURL] { + // TODO(tyiu) Ideally this list would be sorted by the event author's outbox relay preferences + // and reliability of relays to maximize chances of others finding this event. + if let relays = pool.seen[event.id] { + return Array(relays) + } + + return [] + } } diff --git a/damus/Models/ProfileModel.swift b/damus/Models/ProfileModel.swift @@ -22,8 +22,6 @@ class ProfileModel: ObservableObject, Equatable { } return nil } - - private let MAX_SHARE_RELAYS = 4 var events: EventHolder let pubkey: Pubkey @@ -222,7 +220,7 @@ class ProfileModel: ObservableObject, Equatable { } func getCappedRelayStrings() -> [String] { - return self.relay_urls?.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? [] + return self.relay_urls?.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString } ?? [] } } diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift @@ -448,17 +448,26 @@ func random_bytes(count: Int) -> Data { return Data(bytes: bytes, count: count) } -func make_boost_event(keypair: FullKeypair, boosted: NostrEvent) -> NostrEvent? { +func make_boost_event(keypair: FullKeypair, boosted: NostrEvent, relayURL: RelayURL?) -> NostrEvent? { var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag }) - tags.append(["e", boosted.id.hex(), "", "root"]) - tags.append(["p", boosted.pubkey.hex()]) + var eTagBuilder = ["e", boosted.id.hex()] + var pTagBuilder = ["p", boosted.pubkey.hex()] + + let relayURLString = relayURL?.absoluteString + if let relayURLString { + pTagBuilder.append(relayURLString) + } + eTagBuilder.append(contentsOf: [relayURLString ?? "", "root", boosted.pubkey.hex()]) + + tags.append(eTagBuilder) + tags.append(pTagBuilder) let content = event_to_json(ev: boosted) return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 6, tags: tags) } -func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙") -> NostrEvent? { +func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙", relayURL: RelayURL?) -> NostrEvent? { var tags = liked.tags.reduce(into: [[String]]()) { ts, tag in guard tag.count >= 2, (tag[0].matches_char("e") || tag[0].matches_char("p")) else { @@ -467,8 +476,17 @@ func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = ts.append(tag.strings()) } - tags.append(["e", liked.id.hex()]) - tags.append(["p", liked.pubkey.hex()]) + var eTagBuilder = ["e", liked.id.hex()] + var pTagBuilder = ["p", liked.pubkey.hex()] + + let relayURLString = relayURL?.absoluteString + if let relayURLString { + pTagBuilder.append(relayURLString) + } + eTagBuilder.append(contentsOf: [relayURLString ?? "", liked.pubkey.hex()]) + + tags.append(eTagBuilder) + tags.append(pTagBuilder) return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags) } diff --git a/damus/Nostr/RelayPool.swift b/damus/Nostr/RelayPool.swift @@ -19,17 +19,12 @@ struct QueuedRequest { let skip_ephemeral: Bool } -struct SeenEvent: Hashable { - let relay_id: RelayURL - let evid: NoteId -} - /// Establishes and manages connections and subscriptions to a list of relays. class RelayPool { private(set) var relays: [Relay] = [] var handlers: [RelayHandler] = [] var request_queue: [QueuedRequest] = [] - var seen: Set<SeenEvent> = Set() + var seen: [NoteId: Set<RelayURL>] = [:] var counts: [RelayURL: UInt64] = [:] var ndb: Ndb /// The keypair used to authenticate with relays @@ -357,15 +352,11 @@ class RelayPool { func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) { if case .nostr_event(let ev) = event { if case .event(_, let nev) = ev { - let k = SeenEvent(relay_id: relay_id, evid: nev.id) - if !seen.contains(k) { - seen.insert(k) - if counts[relay_id] == nil { - counts[relay_id] = 1 - } else { - counts[relay_id] = (counts[relay_id] ?? 0) + 1 - } + if seen[nev.id]?.contains(relay_id) == true { + return } + seen[nev.id, default: Set()].insert(relay_id) + counts[relay_id, default: 0] += 1 } } } diff --git a/damus/Util/Bech32Object.swift b/damus/Util/Bech32Object.swift @@ -47,6 +47,10 @@ struct NEvent : Equatable, Hashable { self.author = author self.kind = kind } + + init(event: NostrEvent, relays: [String]) { + self.init(noteid: event.id, relays: relays, author: event.pubkey, kind: event.kind) + } } struct NProfile : Equatable, Hashable { diff --git a/damus/Util/Constants.swift b/damus/Util/Constants.swift @@ -45,4 +45,5 @@ class Constants { // MARK: General constants static let GIF_IMAGE_TYPE: String = "com.compuserve.gif" + static let MAX_SHARE_RELAYS = 4 } diff --git a/damus/Views/ActionBar/EventActionBar.swift b/damus/Views/ActionBar/EventActionBar.swift @@ -217,7 +217,16 @@ struct EventActionBar: View { AnyView(self.action_bar_content) } } - + + var event_relay_url_strings: [String] { + let relays = damus_state.nostrNetwork.relaysForEvent(event: event) + if !relays.isEmpty { + return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString } + } + + return userProfile.getCappedRelayStrings() + } + var body: some View { self.content .onAppear { @@ -233,7 +242,9 @@ struct EventActionBar: View { } } .sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) { - ShareSheet(activityItems: [URL(string: "https://damus.io/" + event.id.bech32)!]) + if let url = URL(string: "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))) { + ShareSheet(activityItems: [url]) + } } .sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) { @@ -262,7 +273,7 @@ struct EventActionBar: View { func send_like(emoji: String) { guard let keypair = damus_state.keypair.to_full(), - let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else { + let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else { return } diff --git a/damus/Views/ActionBar/RepostAction.swift b/damus/Views/ActionBar/RepostAction.swift @@ -21,7 +21,7 @@ struct RepostAction: View { dismiss() guard let keypair = self.damus_state.keypair.to_full(), - let boost = make_boost_event(keypair: keypair, boosted: self.event) else { + let boost = make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else { return } diff --git a/damus/Views/ActionBar/ShareAction.swift b/damus/Views/ActionBar/ShareAction.swift @@ -26,7 +26,16 @@ struct ShareAction: View { self.userProfile = userProfile self._show_share = show_share } - + + var event_relay_url_strings: [String] { + let relays = userProfile.damus.nostrNetwork.relaysForEvent(event: event) + if !relays.isEmpty { + return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString } + } + + return userProfile.getCappedRelayStrings() + } + var body: some View { VStack { @@ -40,7 +49,7 @@ struct ShareAction: View { ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note")) { dismiss() - UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(noteid: event.id, relays: userProfile.getCappedRelayStrings()))) + UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings))) } let bookmarkImg = isBookmarked ? "bookmark.fill" : "bookmark" diff --git a/damus/Views/Chat/ChatEventView.swift b/damus/Views/Chat/ChatEventView.swift @@ -235,7 +235,7 @@ struct ChatEventView: View { func send_like(emoji: String) { guard let keypair = damus_state.keypair.to_full(), - let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else { + let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else { return } diff --git a/damus/Views/Events/EventMenu.swift b/damus/Views/Events/EventMenu.swift @@ -63,7 +63,16 @@ struct MenuItems: View { self.target_pubkey = target_pubkey self.profileModel = profileModel } - + + var event_relay_url_strings: [String] { + let relays = damus_state.nostrNetwork.relaysForEvent(event: event) + if !relays.isEmpty { + return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString } + } + + return profileModel.getCappedRelayStrings() + } + var body: some View { Group { Button { @@ -79,7 +88,7 @@ struct MenuItems: View { } Button { - UIPasteboard.general.string = event.id.bech32 + UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings))) } label: { Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book") } diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift @@ -798,18 +798,18 @@ private func isAlphanumeric(_ char: Character) -> Bool { return char.isLetter || char.isNumber } -func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair) -> [[String]] { +func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair, relayURL: RelayURL?) -> [[String]] { guard let nip10 = replying_to.thread_reply() 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"]] + return [["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "root"]] } // otherwise use the root tag from the parent's nip10 reply and include the note // that we are replying to's note id. let tags = [ ["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"], - ["e", replying_to.id.hex(), "", "reply"] + ["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "reply"] ] return tags @@ -902,15 +902,19 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction, 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) + tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair, relayURL: state.nostrNetwork.relaysForEvent(event: replying_to).first) case .quoting(let ev): - content.append("\n\nnostr:" + bech32_note_id(ev.id)) + let relay_urls = state.nostrNetwork.relaysForEvent(event: ev) + let nevent = Bech32Object.encode(.nevent(NEvent(event: ev, relays: relay_urls.prefix(4).map { $0.absoluteString }))) + content.append("\n\nnostr:\(nevent)") - tags.append(["q", ev.id.hex()]); - - if let quoted_ev = state.events.lookup(ev.id) { - tags.append(["p", quoted_ev.pubkey.hex()]) + if let first_relay = relay_urls.first?.absoluteString { + tags.append(["q", ev.id.hex(), first_relay, ev.pubkey.hex()]); + tags.append(["p", ev.pubkey.hex(), first_relay]) + } else { + tags.append(["q", ev.id.hex(), "", ev.pubkey.hex()]); + tags.append(["p", ev.pubkey.hex()]) } case .posting, .highlighting, .sharing: break diff --git a/damusTests/Bech32ObjectTests.swift b/damusTests/Bech32ObjectTests.swift @@ -167,7 +167,23 @@ class Bech32ObjectTests: XCTestCase { XCTAssertEqual(expectedEncoding, actualEncoding) } - + + func testTLVEncoding_NeventFromNostrEvent_ValidContent() throws { + let relays = ["wss://relay.damus.io", "wss://relay.nostr.band"] + let nevent = NEvent(event: test_note, relays: relays) + + XCTAssertEqual(nevent.noteid, test_note.id) + XCTAssertEqual(nevent.relays, relays) + XCTAssertEqual(nevent.author, test_note.pubkey) + XCTAssertEqual(nevent.kind, test_note.kind) + + let expectedEncoding = "nevent1qqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3vamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmnyqgsgydql3q4ka27d9wnlrmus4tvkrnc8ftc4h8h5fgyln54gl0a7dgsrqsqqqqqpppe7n6" + + let actualEncoding = Bech32Object.encode(.nevent(NEvent(event: test_note, relays: relays))) + + XCTAssertEqual(expectedEncoding, actualEncoding) + } + func testTLVEncoding_NProfileExample_ValidContent() throws { guard let author = try bech32_decode("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6") else { XCTFail() diff --git a/damusTests/LikeTests.swift b/damusTests/LikeTests.swift @@ -25,7 +25,7 @@ class LikeTests: XCTestCase { keypair: test_keypair, tags: [cindy.tag, bob.tag])! let id = liked.id - let like_ev = make_like_event(keypair: test_keypair_full, liked: liked)! + let like_ev = make_like_event(keypair: test_keypair_full, liked: liked, relayURL: nil)! XCTAssertTrue(like_ev.referenced_pubkeys.contains(test_keypair.pubkey)) XCTAssertTrue(like_ev.referenced_pubkeys.contains(cindy)) @@ -36,12 +36,12 @@ class LikeTests: XCTestCase { func testToReactionEmoji() { let liked = NostrEvent(content: "awesome #[0] post", keypair: test_keypair, tags: [["p", "cindy"], ["e", "bob"]])! - let emptyReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "")! - let plusReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "+")! - let minusReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "-")! - let heartReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "❤️")! - let thumbsUpReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "👍")! - let shakaReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "🤙")! + let emptyReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "", relayURL: nil)! + let plusReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "+", relayURL: nil)! + let minusReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "-", relayURL: nil)! + let heartReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "❤️", relayURL: nil)! + let thumbsUpReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "👍", relayURL: nil)! + let shakaReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "🤙", relayURL: nil)! XCTAssertEqual(to_reaction_emoji(ev: emptyReaction), "❤️") XCTAssertEqual(to_reaction_emoji(ev: plusReaction), "❤️") diff --git a/damusTests/PostViewTests.swift b/damusTests/PostViewTests.swift @@ -174,7 +174,7 @@ final class PostViewTests: XCTestCase { func testQuoteRepost() { let post = build_post(state: test_damus_state, post: .init(), action: .quoting(test_note), uploadedMedias: [], pubkeys: []) - XCTAssertEqual(post.tags, [["q", test_note.id.hex()]]) + XCTAssertEqual(post.tags, [["q", test_note.id.hex(), "", jack_keypair.pubkey.hex()], ["p", jack_keypair.pubkey.hex()]]) } func testBuildPostRecognizesStringsAsNpubs() throws {