Compare commits

...

1 Commits

Author SHA1 Message Date
eddd908fa3 Add relay hints to tags and identifiers
Changelog-Added: Add relay hints to tags and identifiers
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-07-16 11:29:02 -04:00
16 changed files with 149 additions and 53 deletions

View File

@@ -64,10 +64,35 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
switch self { switch self {
case .pubkey(let pubkey): return ["p", pubkey.hex()] case .pubkey(let pubkey): return ["p", pubkey.hex()]
case .note(let noteId): return ["e", noteId.hex()] case .note(let noteId): return ["e", noteId.hex()]
case .nevent(let nevent): return ["e", nevent.noteid.hex()] case .nevent(let nevent):
case .nprofile(let nprofile): return ["p", nprofile.author.hex()] 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 .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
} }
} }

View File

@@ -51,6 +51,16 @@ class NostrNetworkManager {
func connect() { func connect() {
self.userRelayList.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 []
}
} }

View File

@@ -23,8 +23,6 @@ class ProfileModel: ObservableObject, Equatable {
return nil return nil
} }
private let MAX_SHARE_RELAYS = 4
var events: EventHolder var events: EventHolder
let pubkey: Pubkey let pubkey: Pubkey
let damus: DamusState let damus: DamusState
@@ -222,7 +220,7 @@ class ProfileModel: ObservableObject, Equatable {
} }
func getCappedRelayStrings() -> [String] { 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 } ?? []
} }
} }

View File

@@ -448,17 +448,26 @@ func random_bytes(count: Int) -> Data {
return Data(bytes: bytes, count: count) 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 }) var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
tags.append(["e", boosted.id.hex(), "", "root"]) var eTagBuilder = ["e", boosted.id.hex()]
tags.append(["p", boosted.pubkey.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) let content = event_to_json(ev: boosted)
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 6, tags: tags) 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 var tags = liked.tags.reduce(into: [[String]]()) { ts, tag in
guard tag.count >= 2, guard tag.count >= 2,
(tag[0].matches_char("e") || tag[0].matches_char("p")) else { (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()) ts.append(tag.strings())
} }
tags.append(["e", liked.id.hex()]) var eTagBuilder = ["e", liked.id.hex()]
tags.append(["p", liked.pubkey.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) return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags)
} }

View File

@@ -19,17 +19,12 @@ struct QueuedRequest {
let skip_ephemeral: Bool 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. /// Establishes and manages connections and subscriptions to a list of relays.
class RelayPool { class RelayPool {
private(set) var relays: [Relay] = [] private(set) var relays: [Relay] = []
var handlers: [RelayHandler] = [] var handlers: [RelayHandler] = []
var request_queue: [QueuedRequest] = [] var request_queue: [QueuedRequest] = []
var seen: Set<SeenEvent> = Set() var seen: [NoteId: Set<RelayURL>] = [:]
var counts: [RelayURL: UInt64] = [:] var counts: [RelayURL: UInt64] = [:]
var ndb: Ndb var ndb: Ndb
/// The keypair used to authenticate with relays /// The keypair used to authenticate with relays
@@ -357,15 +352,11 @@ class RelayPool {
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) { func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
if case .nostr_event(let ev) = event { if case .nostr_event(let ev) = event {
if case .event(_, let nev) = ev { if case .event(_, let nev) = ev {
let k = SeenEvent(relay_id: relay_id, evid: nev.id) if seen[nev.id]?.contains(relay_id) == true {
if !seen.contains(k) { return
seen.insert(k)
if counts[relay_id] == nil {
counts[relay_id] = 1
} else {
counts[relay_id] = (counts[relay_id] ?? 0) + 1
}
} }
seen[nev.id, default: Set()].insert(relay_id)
counts[relay_id, default: 0] += 1
} }
} }
} }

View File

@@ -47,6 +47,10 @@ struct NEvent : Equatable, Hashable {
self.author = author self.author = author
self.kind = kind 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 { struct NProfile : Equatable, Hashable {

View File

@@ -45,4 +45,5 @@ class Constants {
// MARK: General constants // MARK: General constants
static let GIF_IMAGE_TYPE: String = "com.compuserve.gif" static let GIF_IMAGE_TYPE: String = "com.compuserve.gif"
static let MAX_SHARE_RELAYS = 4
} }

View File

@@ -218,6 +218,15 @@ struct EventActionBar: View {
} }
} }
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 { var body: some View {
self.content self.content
.onAppear { .onAppear {
@@ -233,7 +242,9 @@ struct EventActionBar: View {
} }
} }
.sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) { .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 }) { .sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) {
@@ -262,7 +273,7 @@ struct EventActionBar: View {
func send_like(emoji: String) { func send_like(emoji: String) {
guard let keypair = damus_state.keypair.to_full(), 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 return
} }

View File

@@ -21,7 +21,7 @@ struct RepostAction: View {
dismiss() dismiss()
guard let keypair = self.damus_state.keypair.to_full(), 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 return
} }

View File

@@ -27,6 +27,15 @@ struct ShareAction: View {
self._show_share = show_share 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 { var body: some View {
VStack { VStack {
@@ -40,7 +49,7 @@ struct ShareAction: View {
ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note")) { ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note")) {
dismiss() 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" let bookmarkImg = isBookmarked ? "bookmark.fill" : "bookmark"

View File

@@ -235,7 +235,7 @@ struct ChatEventView: View {
func send_like(emoji: String) { func send_like(emoji: String) {
guard let keypair = damus_state.keypair.to_full(), 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 return
} }

View File

@@ -64,6 +64,15 @@ struct MenuItems: View {
self.profileModel = profileModel 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 { var body: some View {
Group { Group {
Button { Button {
@@ -79,7 +88,7 @@ struct MenuItems: View {
} }
Button { Button {
UIPasteboard.general.string = event.id.bech32 UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))
} label: { } label: {
Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book") Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book")
} }

View File

@@ -798,18 +798,18 @@ private func isAlphanumeric(_ char: Character) -> Bool {
return char.isLetter || char.isNumber 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 { guard let nip10 = replying_to.thread_reply() else {
// we're replying to a post that isn't in a thread, // we're replying to a post that isn't in a thread,
// just add a single reply-to-root tag // 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 // otherwise use the root tag from the parent's nip10 reply and include the note
// that we are replying to's note id. // that we are replying to's note id.
let tags = [ let tags = [
["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"], ["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 return tags
@@ -902,15 +902,19 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction,
switch action { switch action {
case .replying_to(let replying_to): case .replying_to(let replying_to):
// start off with the reply tags // 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): 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 first_relay = relay_urls.first?.absoluteString {
tags.append(["q", ev.id.hex(), first_relay, ev.pubkey.hex()]);
if let quoted_ev = state.events.lookup(ev.id) { tags.append(["p", ev.pubkey.hex(), first_relay])
tags.append(["p", quoted_ev.pubkey.hex()]) } else {
tags.append(["q", ev.id.hex(), "", ev.pubkey.hex()]);
tags.append(["p", ev.pubkey.hex()])
} }
case .posting, .highlighting, .sharing: case .posting, .highlighting, .sharing:
break break

View File

@@ -168,6 +168,22 @@ class Bech32ObjectTests: XCTestCase {
XCTAssertEqual(expectedEncoding, actualEncoding) 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 { func testTLVEncoding_NProfileExample_ValidContent() throws {
guard let author = try bech32_decode("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6") else { guard let author = try bech32_decode("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6") else {
XCTFail() XCTFail()

View File

@@ -25,7 +25,7 @@ class LikeTests: XCTestCase {
keypair: test_keypair, keypair: test_keypair,
tags: [cindy.tag, bob.tag])! tags: [cindy.tag, bob.tag])!
let id = liked.id 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(test_keypair.pubkey))
XCTAssertTrue(like_ev.referenced_pubkeys.contains(cindy)) XCTAssertTrue(like_ev.referenced_pubkeys.contains(cindy))
@@ -36,12 +36,12 @@ class LikeTests: XCTestCase {
func testToReactionEmoji() { func testToReactionEmoji() {
let liked = NostrEvent(content: "awesome #[0] post", keypair: test_keypair, tags: [["p", "cindy"], ["e", "bob"]])! 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 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: "+")! 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: "-")! 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: "❤️")! 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: "👍")! 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: "🤙")! 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: emptyReaction), "❤️")
XCTAssertEqual(to_reaction_emoji(ev: plusReaction), "❤️") XCTAssertEqual(to_reaction_emoji(ev: plusReaction), "❤️")

View File

@@ -174,7 +174,7 @@ final class PostViewTests: XCTestCase {
func testQuoteRepost() { func testQuoteRepost() {
let post = build_post(state: test_damus_state, post: .init(), action: .quoting(test_note), uploadedMedias: [], pubkeys: []) 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 { func testBuildPostRecognizesStringsAsNpubs() throws {