commit fffaa9e2af82ee80fe9829c9735cc0615e04fc35
parent 01bea06d20cbe186690d0e8c0358dce25b0ca027
Author: alltheseas <64376233+alltheseas@users.noreply.github.com>
Date: Thu, 18 Dec 2025 23:36:43 -0600
feat: integrate rust-nostr PR #1172 relay provenance tracking
Integrate relay provenance tracking from rust-nostr PR #1172 to enable
proper NIP-19 bech32 links with relay hints for better content discoverability.
Changes:
- Update nostr-sdk to alltheseas/rust-nostr relay-provenance-tracking branch
- RelayPool::stream_events returns BoxedStream<RelayEvent> with source relay URL
- NoteAndProfileRenderData stores source_relays captured during fetch
- Generate bech32 links with relay hints for all event types (notes, articles, highlights)
- Filter profile (kind 0) relays from note hints
- Prioritize default relays in source_relays for reliability
- Preserve author/kind fields when rebuilding nevent bech32
- Graceful fallback to original nip19 on encoding failure with metric
- Add bech32_with_relays() helper with 8 unit tests
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
| M | Cargo.lock | | | 246 | +++++++++++++++++++++++++------------------------------------------------------ |
| M | Cargo.toml | | | 7 | +++++-- |
| M | src/html.rs | | | 62 | ++++++++++++++++++++++++++++++++------------------------------ |
| M | src/main.rs | | | 1 | - |
| M | src/nip19.rs | | | 208 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- |
| M | src/relay_pool.rs | | | 26 | ++++++++++++-------------- |
| M | src/render.rs | | | 176 | +++++++++++++++++++++++++++++++++++++++++++++++++------------------------------ |
7 files changed, 438 insertions(+), 288 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -104,21 +104,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
-name = "async-trait"
-version = "0.1.89"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.111",
-]
-
-[[package]]
name = "async-utility"
-version = "0.2.0"
+version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a349201d80b4aa18d17a34a182bdd7f8ddf845e9e57d2ea130a12e10ef1e3a47"
+checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151"
dependencies = [
"futures-util",
"gloo-timers",
@@ -128,9 +117,9 @@ dependencies = [
[[package]]
name = "async-wsocket"
-version = "0.10.1"
+version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d50cb541e6d09e119e717c64c46ed33f49be7fa592fa805d56c11d6a7ff093c"
+checksum = "9a7d8c7d34a225ba919dd9ba44d4b9106d20142da545e086be8ae21d1897e043"
dependencies = [
"async-utility",
"futures",
@@ -147,12 +136,9 @@ dependencies = [
[[package]]
name = "atomic-destructor"
-version = "0.2.0"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d919cb60ba95c87ba42777e9e246c4e8d658057299b437b7512531ce0a09a23"
-dependencies = [
- "tracing",
-]
+checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4"
[[package]]
name = "atomic-waker"
@@ -167,16 +153,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
-name = "base58ck"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f"
-dependencies = [
- "bitcoin-internals",
- "bitcoin_hashes",
-]
-
-[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -269,49 +245,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
-name = "bitcoin"
-version = "0.32.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66"
-dependencies = [
- "base58ck",
- "bech32",
- "bitcoin-internals",
- "bitcoin-io",
- "bitcoin-units",
- "bitcoin_hashes",
- "hex-conservative",
- "hex_lit",
- "secp256k1",
- "serde",
-]
-
-[[package]]
-name = "bitcoin-internals"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2"
-dependencies = [
- "serde",
-]
-
-[[package]]
name = "bitcoin-io"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953"
[[package]]
-name = "bitcoin-units"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2"
-dependencies = [
- "bitcoin-internals",
- "serde",
-]
-
-[[package]]
name = "bitcoin_hashes"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -353,6 +292,12 @@ dependencies = [
]
[[package]]
+name = "btreecap"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6160c957d8aa33d0a8ba1dbab98e3cb57023ad9374c501441e88559f99e6c4c9"
+
+[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -543,7 +488,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
- "rand_core",
"typenum",
]
@@ -848,12 +792,6 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
-
-[[package]]
-name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
@@ -1007,10 +945,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
- "js-sys",
"libc",
"wasi",
- "wasm-bindgen",
]
[[package]]
@@ -1043,9 +979,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "gloo-timers"
-version = "0.2.6"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c"
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"futures-channel",
"futures-core",
@@ -1094,24 +1030,13 @@ dependencies = [
[[package]]
name = "hashbrown"
-version = "0.15.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
-dependencies = [
- "allocator-api2",
- "equivalent",
- "foldhash 0.1.5",
-]
-
-[[package]]
-name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash 0.2.0",
+ "foldhash",
]
[[package]]
@@ -1142,12 +1067,6 @@ dependencies = [
]
[[package]]
-name = "hex_lit"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
-
-[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1491,9 +1410,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
- "js-sys",
- "wasm-bindgen",
- "web-sys",
]
[[package]]
@@ -1626,7 +1542,7 @@ dependencies = [
"minisign-verify",
"pkg-config",
"tar",
- "ureq 3.1.4",
+ "ureq 3.2.0",
"vcpkg",
"zip",
]
@@ -1675,12 +1591,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
-version = "0.12.5"
+version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
-dependencies = [
- "hashbrown 0.15.5",
-]
+checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f"
[[package]]
name = "mac"
@@ -1856,15 +1769,9 @@ dependencies = [
[[package]]
name = "negentropy"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e664971378a3987224f7a0e10059782035e89899ae403718ee07de85bec42afe"
-
-[[package]]
-name = "negentropy"
-version = "0.4.3"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43a88da9dd148bbcdce323dd6ac47d369b4769d4a3b78c6c52389b9269f77932"
+checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d"
[[package]]
name = "new_debug_unreachable"
@@ -1890,24 +1797,22 @@ dependencies = [
[[package]]
name = "nostr"
-version = "0.37.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8aad4b767bbed24ac5eb4465bfb83bc1210522eb99d67cf4e547ec2ec7e47786"
+version = "0.44.1"
+source = "git+https://github.com/alltheseas/rust-nostr.git?branch=relay-provenance-tracking#2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5"
dependencies = [
- "async-trait",
"base64 0.22.1",
"bech32",
"bip39",
- "bitcoin",
+ "bitcoin_hashes",
"cbc",
"chacha20",
"chacha20poly1305",
- "getrandom 0.2.16",
+ "hex",
"instant",
- "negentropy 0.3.1",
- "negentropy 0.4.3",
"once_cell",
+ "rand 0.9.2",
"scrypt",
+ "secp256k1",
"serde",
"serde_json",
"unicode-normalization",
@@ -1916,49 +1821,50 @@ dependencies = [
[[package]]
name = "nostr-database"
-version = "0.37.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23696338d51e45cd44e061823847f4b0d1d362eca80d5033facf9c184149f72f"
+version = "0.44.0"
+source = "git+https://github.com/alltheseas/rust-nostr.git?branch=relay-provenance-tracking#2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5"
dependencies = [
- "async-trait",
+ "btreecap",
"lru",
"nostr",
- "thiserror 1.0.69",
"tokio",
- "tracing",
+]
+
+[[package]]
+name = "nostr-gossip"
+version = "0.44.0"
+source = "git+https://github.com/alltheseas/rust-nostr.git?branch=relay-provenance-tracking#2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5"
+dependencies = [
+ "nostr",
]
[[package]]
name = "nostr-relay-pool"
-version = "0.37.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15fcc6e3f0ca54d0fc779009bc5f2684cea9147be3b6aa68a7d301ea590f95f5"
+version = "0.44.0"
+source = "git+https://github.com/alltheseas/rust-nostr.git?branch=relay-provenance-tracking#2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5"
dependencies = [
"async-utility",
"async-wsocket",
"atomic-destructor",
- "negentropy 0.3.1",
- "negentropy 0.4.3",
+ "hex",
+ "lru",
+ "negentropy",
"nostr",
"nostr-database",
- "thiserror 1.0.69",
"tokio",
- "tokio-stream",
"tracing",
]
[[package]]
name = "nostr-sdk"
-version = "0.37.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "491221fc89b1aa189a0de640127127d68b4e7c5c1d44371b04d9a6d10694b5af"
+version = "0.44.1"
+source = "git+https://github.com/alltheseas/rust-nostr.git?branch=relay-provenance-tracking#2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5"
dependencies = [
"async-utility",
- "atomic-destructor",
"nostr",
"nostr-database",
+ "nostr-gossip",
"nostr-relay-pool",
- "thiserror 1.0.69",
"tokio",
"tracing",
]
@@ -2133,7 +2039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
- "rand_core",
+ "rand_core 0.6.4",
"subtle",
]
@@ -2186,7 +2092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
- "rand",
+ "rand 0.8.5",
]
[[package]]
@@ -2365,19 +2271,27 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
- "libc",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+dependencies = [
"rand_chacha",
- "rand_core",
+ "rand_core 0.9.3",
]
[[package]]
name = "rand_chacha"
-version = "0.3.1"
+version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
- "rand_core",
+ "rand_core 0.9.3",
]
[[package]]
@@ -2390,6 +2304,15 @@ dependencies = [
]
[[package]]
+name = "rand_core"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2653,8 +2576,6 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
- "bitcoin_hashes",
- "rand",
"secp256k1-sys",
"serde",
]
@@ -2733,7 +2654,6 @@ version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
- "indexmap",
"itoa",
"memchr",
"ryu",
@@ -3196,21 +3116,10 @@ dependencies = [
]
[[package]]
-name = "tokio-stream"
-version = "0.1.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
-dependencies = [
- "futures-core",
- "pin-project-lite",
- "tokio",
-]
-
-[[package]]
name = "tokio-tungstenite"
-version = "0.24.0"
+version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
+checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
@@ -3327,21 +3236,20 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
-version = "0.24.0"
+version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
+checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
- "byteorder",
"bytes",
"data-encoding",
"http 1.4.0",
"httparse",
"log",
- "rand",
+ "rand 0.9.2",
"rustls",
"rustls-pki-types",
"sha1",
- "thiserror 1.0.69",
+ "thiserror 2.0.17",
"utf-8",
]
@@ -3418,9 +3326,9 @@ dependencies = [
[[package]]
name = "ureq"
-version = "3.1.4"
+version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
+checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc"
dependencies = [
"base64 0.22.1",
"log",
diff --git a/Cargo.toml b/Cargo.toml
@@ -17,8 +17,11 @@ nostrdb = "0.9.0"
#nostrdb = { path = "/home/jb55/src/rust/nostrdb-rs" }
#nostrdb = "0.1.6"
#nostr-sdk = { git = "https://github.com/damus-io/nostr-sdk.git", rev = "fc0dc7b38f5060f171228b976b9700c0135245d3" }
-nostr-sdk = "0.37.0"
-nostr = "0.37.0"
+#nostr-sdk = "0.37.0"
+#nostr = "0.37.0"
+# PR #1172: relay provenance tracking - https://github.com/rust-nostr/nostr/pull/1172
+nostr-sdk = { git = "https://github.com/alltheseas/rust-nostr.git", branch = "relay-provenance-tracking" }
+nostr = { git = "https://github.com/alltheseas/rust-nostr.git", branch = "relay-provenance-tracking" }
hex = "0.4.3"
egui = "0.23.0"
egui_extras = { version = "0.23.0", features = ["image", "svg"] }
diff --git a/src/html.rs b/src/html.rs
@@ -511,11 +511,7 @@ fn extract_quote_refs_from_content(note: &Note, blocks: &Blocks) -> Vec<QuoteRef
let bech32_str = block.as_str();
// Parse to get relay hints from nevent
if let Ok(Nip19::Event(ev)) = Nip19::from_bech32(bech32_str) {
- let relays: Vec<RelayUrl> = ev
- .relays
- .iter()
- .filter_map(|s| RelayUrl::parse(s).ok())
- .collect();
+ let relays: Vec<RelayUrl> = ev.relays.to_vec();
quotes.push(QuoteRef::Event {
id: *ev.event_id.as_bytes(),
bech32: Some(bech32_str.to_string()),
@@ -596,11 +592,7 @@ fn extract_quote_refs_from_tags(note: &Note) -> Vec<QuoteRef> {
match nip19 {
Nip19::Event(ev) => {
// Combine relays from nevent with q tag relay hint
- let mut relays: Vec<RelayUrl> = ev
- .relays
- .iter()
- .filter_map(|s| RelayUrl::parse(s).ok())
- .collect();
+ let mut relays: Vec<RelayUrl> = ev.relays.to_vec();
if let Some(hint) = &tag_relay_hint {
if !relays.contains(hint) {
relays.push(hint.clone());
@@ -752,10 +744,10 @@ fn build_quote_link(quote_ref: &QuoteRef) -> String {
if let Some(b) = bech32 {
return format!("/{}", b);
}
- if let Ok(eid) = EventId::from_slice(id) {
- if let Ok(b) = eid.to_bech32() {
- return format!("/{}", b);
- }
+ if let Ok(b) =
+ EventId::from_slice(id).map(|eid| eid.to_bech32().expect("infallible apparently"))
+ {
+ return format!("/{}", b);
}
}
QuoteRef::Article { addr, bech32, .. } => {
@@ -1093,10 +1085,8 @@ fn build_note_content_html(
base_url,
);
let timestamp_attr = note.created_at().to_string();
- let nevent = Nip19Event::new(
- EventId::from_byte_array(note.id().to_owned()),
- relays.iter().map(|r| r.to_string()),
- );
+ let nevent = Nip19Event::new(EventId::from_byte_array(note.id().to_owned()))
+ .relays(relays.iter().cloned());
let note_id = nevent.to_bech32().unwrap();
// Extract quote refs from q tags and inline mentions
@@ -1370,9 +1360,7 @@ fn build_note_source_link(event_id: &[u8; 32]) -> String {
let Ok(id) = EventId::from_slice(event_id) else {
return String::new();
};
- let Ok(nevent) = id.to_bech32() else {
- return String::new();
- };
+ let nevent = id.to_bech32().expect("infallible");
let href_raw = format!("/{nevent}");
let href = html_escape::encode_double_quoted_attribute(&href_raw);
@@ -2067,7 +2055,22 @@ pub fn serve_note_html(
profile_record,
);
- let note_bech32 = nip19.to_bech32().unwrap();
+ // Generate bech32 with source relay hints for better discoverability.
+ // This applies to all event types (notes, articles, highlights).
+ // Falls back to original nip19 encoding if relay-enhanced encoding fails.
+ let note_bech32 = match crate::nip19::bech32_with_relays(nip19, ¬e_rd.source_relays) {
+ Some(bech32) => bech32,
+ None => {
+ warn!(
+ "failed to encode bech32 with relays for nip19: {:?}, falling back to original",
+ nip19
+ );
+ metrics::counter!("bech32_encode_fallback_total", 1);
+ nip19
+ .to_bech32()
+ .map_err(|e| Error::Generic(format!("failed to encode nip19: {}", e)))?
+ }
+ };
let base_url = get_base_url();
let canonical_url = format!("{}/{}", base_url, note_bech32);
let fallback_image_url = format!("{}/{}.png", base_url, note_bech32);
@@ -2171,14 +2174,13 @@ pub fn serve_note_html(
)
} else {
// Regular notes (kind 1, etc.)
- build_note_content_html(
- app,
- ¬e,
- &txn,
- &base_url,
- &profile,
- &crate::nip19::nip19_relays(nip19),
- )
+ // Use source relays from fetch if available, otherwise fall back to nip19 relay hints
+ let relays = if note_rd.source_relays.is_empty() {
+ crate::nip19::nip19_relays(nip19)
+ } else {
+ note_rd.source_relays.clone()
+ };
+ build_note_content_html(app, ¬e, &txn, &base_url, &profile, &relays)
};
if og_description_raw.is_empty() {
diff --git a/src/main.rs b/src/main.rs
@@ -423,7 +423,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
RelayPool::new(
keys.clone(),
&["wss://relay.damus.io", "wss://nostr.wine", "wss://nos.lol"],
- timeout,
)
.await?,
);
diff --git a/src/nip19.rs b/src/nip19.rs
@@ -1,17 +1,213 @@
-use nostr::nips::nip19::Nip19;
+use nostr::nips::nip19::{Nip19, Nip19Coordinate, Nip19Event};
use nostr_sdk::prelude::*;
/// Do we have relays for this request? If so we can use these when
/// looking for missing data
pub fn nip19_relays(nip19: &Nip19) -> Vec<RelayUrl> {
match nip19 {
- Nip19::Event(ev) => ev
- .relays
- .iter()
- .filter_map(|r| RelayUrl::parse(r).ok())
- .collect(),
+ Nip19::Event(ev) => ev.relays.clone(),
Nip19::Coordinate(coord) => coord.relays.clone(),
Nip19::Profile(p) => p.relays.clone(),
_ => vec![],
}
}
+
+/// Generate a bech32 string with source relay hints.
+/// If source_relays is empty, uses the original nip19 relays.
+/// Otherwise, replaces the relays with source_relays.
+/// Preserves author/kind fields when present.
+pub fn bech32_with_relays(nip19: &Nip19, source_relays: &[RelayUrl]) -> Option<String> {
+ // If no source relays, use original
+ if source_relays.is_empty() {
+ return nip19.to_bech32().ok();
+ }
+
+ match nip19 {
+ Nip19::Event(ev) => {
+ // Preserve author and kind from original nevent
+ let mut new_event = Nip19Event::new(ev.event_id).relays(source_relays.iter().cloned());
+ if let Some(author) = ev.author {
+ new_event = new_event.author(author);
+ }
+ if let Some(kind) = ev.kind {
+ new_event = new_event.kind(kind);
+ }
+ new_event
+ .to_bech32()
+ .ok()
+ .or_else(|| nip19.to_bech32().ok())
+ }
+ Nip19::Coordinate(coord) => {
+ let new_coord =
+ Nip19Coordinate::new(coord.coordinate.clone(), source_relays.iter().cloned());
+ new_coord
+ .to_bech32()
+ .ok()
+ .or_else(|| nip19.to_bech32().ok())
+ }
+ // For other types (note, pubkey), just use original - they don't support relays
+ _ => nip19.to_bech32().ok(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use nostr::nips::nip01::Coordinate;
+ use nostr::prelude::Keys;
+
+ #[test]
+ fn bech32_with_relays_adds_relay_to_nevent() {
+ let event_id = EventId::from_slice(&[1u8; 32]).unwrap();
+ let nip19_event = Nip19Event::new(event_id);
+ let nip19 = Nip19::Event(nip19_event);
+
+ let source_relays = vec![RelayUrl::parse("wss://relay.damus.io").unwrap()];
+ let result = bech32_with_relays(&nip19, &source_relays).expect("should encode");
+
+ // Result should be longer than original (includes relay hint)
+ let original = nip19.to_bech32().unwrap();
+ assert!(
+ result.len() > original.len(),
+ "bech32 with relay should be longer"
+ );
+
+ // Decode and verify relay is included
+ let decoded = Nip19::from_bech32(&result).unwrap();
+ match decoded {
+ Nip19::Event(ev) => {
+ assert!(!ev.relays.is_empty(), "should have relay hints");
+ assert!(ev.relays[0].to_string().contains("relay.damus.io"));
+ }
+ _ => panic!("expected Nip19::Event"),
+ }
+ }
+
+ #[test]
+ fn bech32_with_relays_adds_relay_to_naddr() {
+ let keys = Keys::generate();
+ let coordinate =
+ Coordinate::new(Kind::LongFormTextNote, keys.public_key()).identifier("test-article");
+ let nip19_coord = Nip19Coordinate::new(coordinate.clone(), Vec::<RelayUrl>::new());
+ let nip19 = Nip19::Coordinate(nip19_coord);
+
+ let source_relays = vec![RelayUrl::parse("wss://nostr.wine").unwrap()];
+ let result = bech32_with_relays(&nip19, &source_relays).expect("should encode");
+
+ // Result should be longer than original (includes relay hint)
+ let original = nip19.to_bech32().unwrap();
+ assert!(
+ result.len() > original.len(),
+ "bech32 with relay should be longer"
+ );
+
+ // Decode and verify relay is included
+ let decoded = Nip19::from_bech32(&result).unwrap();
+ match decoded {
+ Nip19::Coordinate(coord) => {
+ assert!(!coord.relays.is_empty(), "should have relay hints");
+ assert!(coord.relays[0].to_string().contains("nostr.wine"));
+ }
+ _ => panic!("expected Nip19::Coordinate"),
+ }
+ }
+
+ #[test]
+ fn bech32_with_relays_empty_returns_original() {
+ let event_id = EventId::from_slice(&[2u8; 32]).unwrap();
+ let relay = RelayUrl::parse("wss://original.relay").unwrap();
+ let nip19_event = Nip19Event::new(event_id).relays([relay.clone()]);
+ let nip19 = Nip19::Event(nip19_event);
+
+ // Empty source_relays should preserve original
+ let result = bech32_with_relays(&nip19, &[]).expect("should encode");
+ let original = nip19.to_bech32().unwrap();
+
+ assert_eq!(
+ result, original,
+ "empty source_relays should return original bech32"
+ );
+ }
+
+ #[test]
+ fn bech32_with_relays_replaces_existing_relays() {
+ let event_id = EventId::from_slice(&[3u8; 32]).unwrap();
+ let original_relay = RelayUrl::parse("wss://original.relay").unwrap();
+ let nip19_event = Nip19Event::new(event_id).relays([original_relay]);
+ let nip19 = Nip19::Event(nip19_event);
+
+ let new_relay = RelayUrl::parse("wss://new.relay").unwrap();
+ let result = bech32_with_relays(&nip19, &[new_relay.clone()]).expect("should encode");
+
+ // Decode and verify new relay replaced original
+ let decoded = Nip19::from_bech32(&result).unwrap();
+ match decoded {
+ Nip19::Event(ev) => {
+ assert_eq!(ev.relays.len(), 1, "should have exactly one relay");
+ assert!(ev.relays[0].to_string().contains("new.relay"));
+ }
+ _ => panic!("expected Nip19::Event"),
+ }
+ }
+
+ #[test]
+ fn bech32_with_relays_preserves_author_and_kind() {
+ let event_id = EventId::from_slice(&[5u8; 32]).unwrap();
+ let keys = Keys::generate();
+ let nip19_event = Nip19Event::new(event_id)
+ .author(keys.public_key())
+ .kind(Kind::TextNote);
+ let nip19 = Nip19::Event(nip19_event);
+
+ let source_relays = vec![RelayUrl::parse("wss://test.relay").unwrap()];
+ let result = bech32_with_relays(&nip19, &source_relays).expect("should encode");
+
+ // Decode and verify author/kind are preserved
+ let decoded = Nip19::from_bech32(&result).unwrap();
+ match decoded {
+ Nip19::Event(ev) => {
+ assert!(ev.author.is_some(), "author should be preserved");
+ assert_eq!(ev.author.unwrap(), keys.public_key());
+ assert!(ev.kind.is_some(), "kind should be preserved");
+ assert_eq!(ev.kind.unwrap(), Kind::TextNote);
+ assert!(!ev.relays.is_empty(), "should have relay");
+ }
+ _ => panic!("expected Nip19::Event"),
+ }
+ }
+
+ #[test]
+ fn nip19_relays_extracts_from_event() {
+ let event_id = EventId::from_slice(&[4u8; 32]).unwrap();
+ let relay = RelayUrl::parse("wss://test.relay").unwrap();
+ let nip19_event = Nip19Event::new(event_id).relays([relay.clone()]);
+ let nip19 = Nip19::Event(nip19_event);
+
+ let relays = nip19_relays(&nip19);
+ assert_eq!(relays.len(), 1);
+ assert!(relays[0].to_string().contains("test.relay"));
+ }
+
+ #[test]
+ fn nip19_relays_extracts_from_coordinate() {
+ let keys = Keys::generate();
+ let coordinate =
+ Coordinate::new(Kind::LongFormTextNote, keys.public_key()).identifier("article");
+ let relay = RelayUrl::parse("wss://coord.relay").unwrap();
+ let nip19_coord = Nip19Coordinate::new(coordinate, [relay.clone()]);
+ let nip19 = Nip19::Coordinate(nip19_coord);
+
+ let relays = nip19_relays(&nip19);
+ assert_eq!(relays.len(), 1);
+ assert!(relays[0].to_string().contains("coord.relay"));
+ }
+
+ #[test]
+ fn nip19_relays_returns_empty_for_pubkey() {
+ let keys = Keys::generate();
+ let nip19 = Nip19::Pubkey(keys.public_key());
+
+ let relays = nip19_relays(&nip19);
+ assert!(relays.is_empty(), "pubkey nip19 should have no relays");
+ }
+}
diff --git a/src/relay_pool.rs b/src/relay_pool.rs
@@ -1,6 +1,6 @@
use crate::Error;
use nostr::prelude::RelayUrl;
-use nostr_sdk::prelude::{Client, Event, Filter, Keys, ReceiverStream};
+use nostr_sdk::prelude::{BoxedStream, Client, Filter, Keys, RelayEvent};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Mutex;
@@ -21,16 +21,11 @@ pub struct RelayPool {
client: Client,
known_relays: Arc<Mutex<HashSet<String>>>,
default_relays: Arc<[RelayUrl]>,
- connect_timeout: Duration,
stats: Arc<Mutex<RelayStats>>,
}
impl RelayPool {
- pub async fn new(
- keys: Keys,
- default_relays: &[&str],
- connect_timeout: Duration,
- ) -> Result<Self, Error> {
+ pub async fn new(keys: Keys, default_relays: &[&str]) -> Result<Self, Error> {
let client = Client::builder().signer(keys).build();
let parsed_defaults: Vec<RelayUrl> = default_relays
.iter()
@@ -48,7 +43,6 @@ impl RelayPool {
client,
known_relays: Arc::new(Mutex::new(HashSet::new())),
default_relays: default_relays.clone(),
- connect_timeout,
stats: Arc::new(Mutex::new(RelayStats::default())),
};
@@ -110,7 +104,7 @@ impl RelayPool {
}
if had_new {
- self.client.connect_with_timeout(self.connect_timeout).await;
+ self.client.connect().await;
let mut stats = self.stats.lock().await;
stats.ensure_calls += 1;
@@ -147,19 +141,23 @@ impl RelayPool {
Ok(())
}
+ /// Stream events from relays, returning RelayEvent which includes source relay URL.
+ /// Takes a single Filter - callers should combine filters before calling.
pub async fn stream_events(
&self,
- filters: Vec<Filter>,
+ filter: Filter,
relays: &[RelayUrl],
timeout: Duration,
- ) -> Result<ReceiverStream<Event>, Error> {
+ ) -> Result<BoxedStream<RelayEvent>, Error> {
if relays.is_empty() {
- Ok(self.client.stream_events(filters, Some(timeout)).await?)
+ Ok(self
+ .client
+ .stream_events_with_source(filter, timeout)
+ .await?)
} else {
- let urls: Vec<String> = relays.iter().map(|r| r.to_string()).collect();
Ok(self
.client
- .stream_events_from(urls, filters, Some(timeout))
+ .stream_events_from_with_source(relays.to_vec(), filter, timeout)
.await?)
}
}
diff --git a/src/render.rs b/src/render.rs
@@ -71,6 +71,9 @@ impl NoteRenderData {
pub struct NoteAndProfileRenderData {
pub note_rd: NoteRenderData,
pub profile_rd: Option<ProfileRenderData>,
+ /// Source relay URL(s) where the note was fetched from.
+ /// Used for generating bech32 links with relay hints.
+ pub source_relays: Vec<RelayUrl>,
}
impl NoteAndProfileRenderData {
@@ -78,6 +81,13 @@ impl NoteAndProfileRenderData {
Self {
note_rd,
profile_rd,
+ source_relays: Vec::new(),
+ }
+ }
+
+ pub fn add_source_relay(&mut self, relay: RelayUrl) {
+ if !self.source_relays.contains(&relay) {
+ self.source_relays.push(relay);
}
}
}
@@ -313,12 +323,15 @@ fn query_note_by_address<'a>(
}
}
+/// Fetches notes from relays and returns the source relay URLs.
+/// The source relays are used to generate bech32 links with relay hints.
+/// Prioritizes default relays in the returned list for better reliability.
pub async fn find_note(
relay_pool: Arc<RelayPool>,
ndb: Ndb,
filters: Vec<nostr::Filter>,
nip19: &Nip19,
-) -> Result<()> {
+) -> Result<Vec<RelayUrl>> {
use nostr_sdk::JsonUtil;
let mut relay_targets = nip19::nip19_relays(nip19);
@@ -330,35 +343,57 @@ pub async fn find_note(
debug!("finding note(s) with filters: {:?}", filters);
- let expected_events = filters.len();
+ let mut all_source_relays = Vec::new();
+ let default_relays = relay_pool.default_relays();
+
+ for filter in filters {
+ let mut streamed_events = relay_pool
+ .stream_events(
+ filter,
+ &relay_targets,
+ std::time::Duration::from_millis(2000),
+ )
+ .await?;
+
+ // Collect all responding relays, then prioritize after stream exhausts.
+ while let Some(relay_event) = streamed_events.next().await {
+ if let Err(err) = ensure_relay_hints(&relay_pool, &relay_event.event).await {
+ warn!("failed to apply relay hints: {err}");
+ }
- let mut streamed_events = relay_pool
- .stream_events(
- filters,
- &relay_targets,
- std::time::Duration::from_millis(2000),
- )
- .await?;
+ debug!("processing event {:?}", relay_event.event);
+ if let Err(err) = ndb.process_event(&relay_event.event.as_json()) {
+ error!("error processing event: {err}");
+ }
- let mut num_loops = 0;
- while let Some(event) = streamed_events.next().await {
- if let Err(err) = ensure_relay_hints(&relay_pool, &event).await {
- warn!("failed to apply relay hints: {err}");
- }
+ // Skip profile events - their relays shouldn't be used as note hints
+ if relay_event.event.kind == Kind::Metadata {
+ continue;
+ }
- debug!("processing event {:?}", event);
- if let Err(err) = ndb.process_event(&event.as_json()) {
- error!("error processing event: {err}");
- }
+ let Some(relay_url) = relay_event.relay_url() else {
+ continue;
+ };
- num_loops += 1;
+ if all_source_relays.contains(relay_url) {
+ continue;
+ }
- if num_loops == expected_events {
- break;
+ all_source_relays.push(relay_url.clone());
}
}
- Ok(())
+ // Sort relays: default relays first (more reliable), then others.
+ // Take up to 3 for the final result.
+ const MAX_SOURCE_RELAYS: usize = 3;
+ all_source_relays.sort_by(|a, b| {
+ let a_is_default = default_relays.contains(a);
+ let b_is_default = default_relays.contains(b);
+ b_is_default.cmp(&a_is_default) // true > false, so defaults come first
+ });
+ all_source_relays.truncate(MAX_SOURCE_RELAYS);
+
+ Ok(all_source_relays)
}
/// Fetch the latest profile metadata (kind 0) from relays and update nostrdb.
@@ -391,7 +426,7 @@ async fn fetch_profile_metadata(
};
let stream = relay_pool
- .stream_events(vec![filter], &relays, Duration::from_millis(2000))
+ .stream_events(filter, &relays, Duration::from_millis(2000))
.await;
let mut stream = match stream {
@@ -583,10 +618,17 @@ impl RenderData {
}
}
- // Wait for primary fetch to complete
- // Note: unknowns collection happens in main.rs after complete() returns
+ // Capture source relay URLs from the fetch task
match fetch_handle.await {
- Ok(Ok(())) => Ok(()),
+ Ok(Ok(source_relays)) => {
+ // Store source relays in the render data for bech32 link generation
+ if let RenderData::Note(ref mut note_data) = self {
+ for relay in source_relays {
+ note_data.add_source_relay(relay);
+ }
+ }
+ Ok(())
+ }
Ok(Err(err)) => Err(err),
Err(join_err) => Err(Error::Generic(format!(
"relay fetch task failed: {}",
@@ -660,13 +702,15 @@ pub async fn fetch_unknowns(
);
// Stream with shorter timeout since these are secondary fetches
- let mut stream = relay_pool
- .stream_events(nostr_filters, &relay_targets, Duration::from_millis(1500))
- .await?;
-
- while let Some(event) = stream.next().await {
- if let Err(err) = ndb.process_event(&event.as_json()) {
- warn!("error processing quoted event: {err}");
+ for filter in nostr_filters {
+ let mut stream = relay_pool
+ .stream_events(filter, &relay_targets, Duration::from_millis(1500))
+ .await?;
+
+ while let Some(event) = stream.next().await {
+ if let Err(err) = ndb.process_event(&event.as_json()) {
+ warn!("error processing quoted event: {err}");
+ }
}
}
@@ -751,34 +795,34 @@ async fn collect_profile_relays(
.build(),
);
- let mut stream = relay_pool
- .stream_events(
- vec![relay_filter, contact_filter],
- &[],
- Duration::from_millis(2000),
- )
- .await?;
- while let Some(event) = stream.next().await {
- if let Err(err) = ndb.process_event(&event.as_json()) {
- error!("error processing relay discovery event: {err}");
- }
+ // Process each filter separately since stream_events now takes a single filter
+ for filter in [relay_filter, contact_filter] {
+ let mut stream = relay_pool
+ .stream_events(filter, &[], Duration::from_millis(2000))
+ .await?;
- let hints = collect_relay_hints(&event);
- if hints.is_empty() {
- continue;
- }
+ while let Some(relay_event) = stream.next().await {
+ if let Err(err) = ndb.process_event(&relay_event.event.as_json()) {
+ error!("error processing relay discovery event: {err}");
+ }
- let mut fresh = Vec::new();
- for hint in hints {
- let key = hint.to_string();
- if known.insert(key) {
- targets.push(hint.clone());
- fresh.push(hint);
+ let hints = collect_relay_hints(&relay_event.event);
+ if hints.is_empty() {
+ continue;
}
- }
- if !fresh.is_empty() {
- relay_pool.ensure_relays(fresh).await?;
+ let mut fresh = Vec::new();
+ for hint in hints {
+ let key = hint.to_string();
+ if known.insert(key) {
+ targets.push(hint.clone());
+ fresh.push(hint);
+ }
+ }
+
+ if !fresh.is_empty() {
+ relay_pool.ensure_relays(fresh).await?;
+ }
}
}
@@ -806,16 +850,16 @@ async fn stream_profile_feed_once(
convert_filter(&builder.build())
};
let mut stream = relay_pool
- .stream_events(vec![filter], relays, Duration::from_millis(2000))
+ .stream_events(filter, &relays, Duration::from_millis(2000))
.await?;
let mut fetched = 0usize;
- while let Some(event) = stream.next().await {
- if let Err(err) = ensure_relay_hints(&relay_pool, &event).await {
+ while let Some(relay_event) = stream.next().await {
+ if let Err(err) = ensure_relay_hints(&relay_pool, &relay_event.event).await {
warn!("failed to apply relay hints: {err}");
}
- if let Err(err) = ndb.process_event(&event.as_json()) {
+ if let Err(err) = ndb.process_event(&relay_event.event.as_json()) {
error!("error processing profile feed event: {err}");
} else {
fetched += 1;
@@ -835,7 +879,7 @@ pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<Re
let pk = if let Some(pk) = m_note.as_ref().map(|note| note.pubkey()) {
Some(*pk)
} else {
- nevent.author.map(|a| a.serialize())
+ nevent.author.map(|a| a.to_bytes())
};
let profile_rd = pk.as_ref().map(|pubkey| {
@@ -878,7 +922,7 @@ pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<Re
}
Nip19::Coordinate(coordinate) => {
- let author = coordinate.public_key.serialize();
+ let author = coordinate.public_key.to_bytes();
let kind: u64 = u16::from(coordinate.kind) as u64;
let identifier = coordinate.identifier.clone();
@@ -912,7 +956,7 @@ pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<Re
}
Nip19::Profile(nprofile) => {
- let pubkey = nprofile.public_key.serialize();
+ let pubkey = nprofile.public_key.to_bytes();
let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) {
ProfileRenderData::Profile(profile_key)
} else {
@@ -923,7 +967,7 @@ pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<Re
}
Nip19::Pubkey(public_key) => {
- let pubkey = public_key.serialize();
+ let pubkey = public_key.to_bytes();
let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) {
ProfileRenderData::Profile(profile_key)
} else {
@@ -1304,7 +1348,7 @@ mod tests {
let coordinate = Coordinate::new(Kind::LongFormTextNote, keys.public_key())
.identifier(identifier_with_a);
let event_with_a_only = EventBuilder::long_form_text_note("content with a tag only")
- .tags([Tag::coordinate(coordinate)])
+ .tags([Tag::coordinate(coordinate, None)])
.sign_with_keys(&keys)
.expect("sign long-form event with coordinate tag");