diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e1edf59..8e18d1a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -16,7 +16,9 @@ jobs: steps: - name: Deps - run: sudo apt-get install libfontconfig1-dev libfreetype6-dev libssl-dev + run: | + sudo apt-get update + sudo apt-get install -y libfontconfig1-dev libfreetype6-dev libssl-dev - uses: actions/checkout@v3 - name: Build run: cargo build --verbose diff --git a/Cargo.lock b/Cargo.lock index c05f0b4..721e6f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "aead" version = "0.5.2" @@ -97,22 +103,11 @@ version = "0.7.6" 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", @@ -122,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", @@ -141,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" @@ -160,16 +152,6 @@ version = "1.5.0" 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" @@ -262,49 +244,12 @@ version = "0.10.3" 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" @@ -346,6 +291,12 @@ dependencies = [ "generic-array", ] +[[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" @@ -381,9 +332,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.49" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", "shlex", @@ -472,6 +423,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -528,7 +488,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", "typenum", ] @@ -555,6 +514,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "dary_heap" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" + [[package]] name = "dashmap" version = "6.1.0" @@ -788,9 +753,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flatbuffers" @@ -809,6 +774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -826,9 +792,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "foreign-types" @@ -979,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]] @@ -1015,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", @@ -1066,21 +1030,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "heck" version = "0.4.1" @@ -1108,12 +1066,6 @@ dependencies = [ "arrayvec", ] -[[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" @@ -1458,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]] @@ -1536,6 +1485,30 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "libflate" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74" +dependencies = [ + "adler32", + "core2", + "crc32fast", + "dary_heap", + "libflate_lz77", +] + +[[package]] +name = "libflate_lz77" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" +dependencies = [ + "core2", + "hashbrown 0.16.1", + "rle-decode-fast", +] + [[package]] name = "libloading" version = "0.8.9" @@ -1557,6 +1530,32 @@ dependencies = [ "redox_syscall 0.6.0", ] +[[package]] +name = "libsodium-sys-stable" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5d23f4a051a13cf1085b2c5a050d4d890d80c754534cc4247eff525fa5283d" +dependencies = [ + "cc", + "libc", + "libflate", + "minisign-verify", + "pkg-config", + "tar", + "ureq 3.2.0", + "vcpkg", + "zip", +] + +[[package]] +name = "libz-rs-sys" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1592,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" @@ -1727,6 +1723,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1767,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" @@ -1801,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", @@ -1827,67 +1821,69 @@ 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", ] [[package]] name = "nostrdb" -version = "0.5.1" -source = "git+https://github.com/damus-io/nostrdb-rs?rev=77fbc99a55f6a5e939176085f9a95cf2a4e7eeb5#77fbc99a55f6a5e939176085f9a95cf2a4e7eeb5" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b2685d093dca579807150b6fafab4dcb974fc6e31017d273ae32d42795d41b" dependencies = [ "bindgen 0.69.5", "cc", "flatbuffers", "futures", "libc", + "libsodium-sys-stable", "thiserror 2.0.17", "tokio", "tracing", - "tracing-subscriber", ] [[package]] @@ -2043,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", ] @@ -2096,7 +2092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -2275,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]] @@ -2299,6 +2303,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[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" @@ -2418,6 +2431,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "roxmltree" version = "0.15.1" @@ -2557,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", ] @@ -2637,7 +2654,6 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap", "itoa", "memchr", "ryu", @@ -2739,7 +2755,7 @@ dependencies = [ "serde_json", "tar", "toml", - "ureq", + "ureq 2.12.1", ] [[package]] @@ -3099,22 +3115,11 @@ dependencies = [ "tokio", ] -[[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", @@ -3231,24 +3236,29 @@ 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", ] +[[package]] +name = "typed-path" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3015e6ce46d5ad8751e4a772543a30c7511468070e98e64e20165f8f81155b64" + [[package]] name = "typenum" version = "1.19.0" @@ -3314,6 +3324,31 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "ureq-proto", + "utf-8", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.7" @@ -3875,6 +3910,38 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "zip" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "268bf6f9ceb991e07155234071501490bb41fd1e39c6a588106dad10ae2a5804" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-inflate" version = "0.2.54" diff --git a/Cargo.toml b/Cargo.toml index 8a890b4..14b5e0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,12 +13,15 @@ hyper-util = { version = "0.1.1", features = ["full"] } http-body-util = "0.1" tracing = "0.1.41" tracing-subscriber = "0.3.19" -nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "77fbc99a55f6a5e939176085f9a95cf2a4e7eeb5" } +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/assets/damus.css b/assets/damus.css index ee3cc40..07a79aa 100644 --- a/assets/damus.css +++ b/assets/damus.css @@ -258,6 +258,13 @@ a:hover { color: #ffffff; } +.damus-note-handle { + display: block; + font-size: 0.95rem; + font-weight: 400; + color: var(--damus-muted); +} + .damus-note-time { font-size: 0.9rem; color: var(--damus-muted); @@ -348,6 +355,58 @@ a:hover { letter-spacing: 0.02em; } +/* Draft badge for unpublished articles (kind:30024) */ +.damus-article-draft { + background: linear-gradient(135deg, #ff6b35, #f7931a); + color: #ffffff; + padding: 0.25em 0.65em; + border-radius: 6px; + font-family: var(--damus-font); + font-size: 0.4em; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-left: 0.75em; + vertical-align: middle; + box-shadow: 0 2px 8px rgba(255, 107, 53, 0.3); +} + +/* NIP-84 Highlight styles (kind:9802) */ +.damus-highlight-text { + border-left: 3px solid var(--damus-accent); + padding-left: 1.25rem; + margin: 0; + font-style: italic; + font-size: 1.15rem; + line-height: 1.7; + color: var(--damus-text); +} + +.damus-highlight-context { + color: var(--damus-muted); + font-size: 0.9rem; + line-height: 1.6; + margin-top: 1rem; +} + +.damus-highlight-source { + font-size: 0.9rem; + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid var(--damus-card-border); +} + +.damus-highlight-source-label { + color: var(--damus-muted); + margin-right: 0.35em; +} + +.damus-highlight-comment { + font-size: 1.1rem; + line-height: 1.6; + margin-bottom: 1rem; +} + .damus-profile-card { gap: 1.5rem; } @@ -441,6 +500,164 @@ a:hover { color: var(--damus-muted); } +/* NIP-18 Embedded Quotes (q tags) */ +.damus-embedded-quotes { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.35rem; +} + +.damus-embedded-quote { + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--damus-card-border); + border-radius: 16px; + padding: 0.875rem 1rem; + transition: border-color 160ms ease; + text-decoration: none; + display: block; +} + +.damus-embedded-quote:hover { + border-color: rgba(189, 102, 255, 0.3); + text-decoration: none; +} + +.damus-embedded-quote-header { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.25rem; + flex-wrap: wrap; +} + +.damus-embedded-quote-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.damus-embedded-quote-author { + font-weight: 600; + color: #ffffff; + font-size: 0.9rem; +} + +.damus-embedded-quote-username { + color: var(--damus-muted); + font-size: 0.85rem; +} + +.damus-embedded-quote-time { + color: var(--damus-muted); + font-size: 0.85rem; +} + +.damus-embedded-quote-type { + background: rgba(189, 102, 255, 0.2); + color: var(--damus-accent); + padding: 0.15em 0.5em; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + margin-left: 0.5rem; +} + +.damus-embedded-quote-type-draft { + background: rgba(255, 107, 53, 0.2); + color: #ff6b35; +} + +.damus-embedded-quote-reply { + color: var(--damus-muted); + font-size: 0.8rem; + margin-bottom: 0.5rem; +} + +.damus-embedded-quote-content { + color: var(--damus-text); + font-size: 0.9rem; + line-height: 1.5; +} + +.damus-embedded-quote-highlight { + border-left: 3px solid var(--damus-accent); + padding-left: 0.75rem; + font-style: italic; +} + +/* Article card styling for embedded quotes */ +.damus-embedded-quote-article { + display: flex; + flex-direction: column; +} + +.damus-embedded-article-image { + width: 100%; + max-height: 180px; + object-fit: cover; + border-radius: 12px; + margin-bottom: 0.75rem; +} + +.damus-embedded-article-title { + font-weight: 700; + font-size: 1rem; + color: #ffffff; + line-height: 1.3; + margin-bottom: 0.35rem; +} + +.damus-embedded-article-title.damus-embedded-article-draft::after { + content: "DRAFT"; + background: linear-gradient(135deg, #ff6b35, #f7931a); + color: #ffffff; + padding: 0.15em 0.4em; + border-radius: 4px; + font-size: 0.6em; + font-weight: 700; + letter-spacing: 0.05em; + margin-left: 0.5em; + vertical-align: middle; +} + +.damus-embedded-article-summary { + font-size: 0.85rem; + color: var(--damus-muted); + line-height: 1.4; + margin-bottom: 0.5rem; +} + +.damus-embedded-article-wordcount { + font-size: 0.8rem; + color: var(--damus-muted); + font-weight: 500; +} + +.damus-embedded-quote-showmore { + color: var(--damus-accent); + font-weight: 500; +} + +.damus-embedded-quote-urls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.damus-embedded-quote-url { + background: rgba(255, 255, 255, 0.08); + color: var(--damus-muted); + padding: 0.4rem 0.75rem; + border-radius: 8px; + font-size: 0.8rem; +} + @media (max-width: 640px) { .damus-header { flex-direction: column; diff --git a/src/html.rs b/src/html.rs index f52a9bb..13a193c 100644 --- a/src/html.rs +++ b/src/html.rs @@ -8,7 +8,7 @@ use ammonia::Builder as HtmlSanitizer; use http_body_util::Full; use hyper::{body::Bytes, header, Request, Response, StatusCode}; use nostr::nips::nip19::Nip19Event; -use nostr_sdk::prelude::{EventId, Nip19, PublicKey, RelayUrl, ToBech32}; +use nostr_sdk::prelude::{EventId, FromBech32, Nip19, PublicKey, RelayUrl, ToBech32}; use nostrdb::{ BlockType, Blocks, Filter, Mention, Ndb, NdbProfile, Note, NoteKey, ProfileRecord, Transaction, }; @@ -18,6 +18,12 @@ use std::io::Write; use std::str::FromStr; use tracing::warn; +struct QuoteProfileInfo { + display_name: Option, + username: Option, + pfp_url: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RelayEntry { url: String, @@ -77,6 +83,33 @@ struct ArticleMetadata { topics: Vec, } +/// Metadata extracted from NIP-84 highlight events (kind:9802). +/// +/// Highlights capture a passage from source content with optional context. +/// Sources can be: web URLs (r tag), nostr notes (e tag), or articles (a tag). +#[derive(Default)] +struct HighlightMetadata { + /// Surrounding text providing context for the highlight (from "context" tag) + context: Option, + /// User's comment/annotation on the highlight (from "comment" tag) + comment: Option, + /// Web URL source - external article or page (from "r" tag) + source_url: Option, + /// Nostr note ID - reference to a kind:1 shortform note (from "e" tag) + source_event_id: Option<[u8; 32]>, + /// Nostr article address - reference to kind:30023/30024 (from "a" tag) + /// Format: "30023:{pubkey_hex}:{d-identifier}" + source_article_addr: Option, +} + +/// Normalizes text for comparison by trimming whitespace and trailing punctuation. +/// Used to detect when context and content are essentially the same text. +fn normalize_for_comparison(s: &str) -> String { + s.trim() + .trim_end_matches(|c: char| c.is_ascii_punctuation()) + .to_lowercase() +} + fn collapse_whitespace>(input: S) -> String { let mut result = String::with_capacity(input.as_ref().len()); let mut last_space = false; @@ -151,6 +184,101 @@ fn extract_article_metadata(note: &Note) -> ArticleMetadata { meta } +/// Extracts NIP-84 highlight metadata from a kind:9802 note. +/// +/// Parses tags to identify the highlight source: +/// - "context" tag: surrounding text for context +/// - "comment" tag: user's annotation +/// - "r" tag: web URL source (external article/page) +/// - "e" tag: nostr note ID (kind:1 shortform note) +/// - "a" tag: nostr article address (kind:30023/30024 longform) +fn extract_highlight_metadata(note: &Note) -> HighlightMetadata { + let mut meta = HighlightMetadata::default(); + + for tag in note.tags() { + let Some(tag_name) = tag.get_str(0) else { + continue; + }; + + match tag_name { + "context" => { + if let Some(value) = tag.get_str(1) { + if !value.trim().is_empty() { + meta.context = Some(value.to_owned()); + } + } + } + + "comment" => { + if let Some(value) = tag.get_str(1) { + if !value.trim().is_empty() { + meta.comment = Some(value.to_owned()); + } + } + } + + "r" => { + if let Some(value) = tag.get_str(1) { + let trimmed = value.trim(); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + meta.source_url = Some(trimmed.to_owned()); + } + } + } + + "e" => { + // The e tag value is guaranteed to be an ID + if let Some(event_id) = tag.get_id(1) { + meta.source_event_id = Some(*event_id); + } + } + + "a" => { + if let Some(value) = tag.get_str(1) { + let trimmed = value.trim(); + if trimmed.starts_with("30023:") || trimmed.starts_with("30024:") { + meta.source_article_addr = Some(trimmed.to_owned()); + } + } + } + + _ => {} + } + } + + meta +} + +/// Formats a unix timestamp as a relative time string (e.g., "6h", "2d", "3w"). +fn format_relative_time(timestamp: u64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + if timestamp > now { + return "now".to_string(); + } + + let diff = now - timestamp; + let minutes = diff / 60; + let hours = diff / 3600; + let days = diff / 86400; + let weeks = diff / 604800; + + if minutes < 1 { + "now".to_string() + } else if minutes < 60 { + format!("{}m", minutes) + } else if hours < 24 { + format!("{}h", hours) + } else if days < 7 { + format!("{}d", days) + } else { + format!("{}w", weeks) + } +} + fn render_markdown(markdown: &str) -> String { let mut options = Options::empty(); options.insert(Options::ENABLE_TABLES); @@ -256,7 +384,21 @@ fn is_image(url: &str) -> bool { .any(|ext| ends_with(strip_querystring(url), ext)) } -pub fn render_note_content(body: &mut Vec, note: &Note, blocks: &Blocks) { +/// Gets the display name for a profile, preferring display_name, falling back to name. +fn get_profile_display_name<'a>(record: Option<&ProfileRecord<'a>>) -> Option<&'a str> { + let profile = record?.record().profile()?; + let display_name = profile.display_name().filter(|n| !n.trim().is_empty()); + let username = profile.name().filter(|n| !n.trim().is_empty()); + display_name.or(username) +} + +pub fn render_note_content( + body: &mut Vec, + note: &Note, + blocks: &Blocks, + ndb: &Ndb, + txn: &Transaction, +) { for block in blocks.iter(note) { match block.blocktype() { BlockType::Url => { @@ -293,33 +435,596 @@ pub fn render_note_content(body: &mut Vec, note: &Note, blocks: &Blocks) { } BlockType::MentionBech32 => { - match block.as_mention().unwrap() { - Mention::Event(_) - | Mention::Note(_) - | Mention::Profile(_) - | Mention::Pubkey(_) - | Mention::Secret(_) - | Mention::Addr(_) => { - let _ = write!( - body, - r#"@{}"#, - block.as_str(), - &abbrev_str(block.as_str()) - ); + let mention = block.as_mention().unwrap(); + let pubkey = match mention { + Mention::Profile(p) => Some(p.pubkey()), + Mention::Pubkey(p) => Some(p.pubkey()), + _ => None, + }; + + if let Some(pk) = pubkey { + // Profile/pubkey mentions: show the human-readable name + let record = ndb.get_profile_by_pubkey(txn, pk).ok(); + let display = get_profile_display_name(record.as_ref()) + .map(|s| s.to_string()) + .unwrap_or_else(|| abbrev_str(block.as_str())); + let display_html = html_escape::encode_text(&display); + let _ = write!( + body, + r#"@{display}"#, + bech32 = block.as_str(), + display = display_html + ); + } else { + match mention { + // Event/note mentions: skip inline rendering (shown as embedded quotes) + Mention::Event(_) | Mention::Note(_) => {} + + // Other mentions: link with abbreviated bech32 + _ => { + let _ = write!( + body, + r#"{abbrev}"#, + bech32 = block.as_str(), + abbrev = abbrev_str(block.as_str()) + ); + } } + } + } + }; + } +} + +/// Represents a quoted event reference from a q tag (NIP-18) or inline mention. +#[derive(Clone, PartialEq)] +pub enum QuoteRef { + Event { + id: [u8; 32], + bech32: Option, + relays: Vec, + }, + Article { + addr: String, + bech32: Option, + relays: Vec, + }, +} + +/// Extracts quote references from inline nevent/note mentions in content. +fn extract_quote_refs_from_content(note: &Note, blocks: &Blocks) -> Vec { + use nostr_sdk::prelude::Nip19; - Mention::Relay(relay) => { - let _ = write!( - body, - r#"{}"#, - block.as_str(), - &abbrev_str(relay.as_str()) + let mut quotes = Vec::new(); + + for block in blocks.iter(note) { + if block.blocktype() != BlockType::MentionBech32 { + continue; + } + + let Some(mention) = block.as_mention() else { + continue; + }; + + match mention { + Mention::Event(_ev) => { + 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 = ev.relays.to_vec(); + quotes.push(QuoteRef::Event { + id: *ev.event_id.as_bytes(), + bech32: Some(bech32_str.to_string()), + relays, + }); + } else if let Ok(Nip19::EventId(id)) = Nip19::from_bech32(bech32_str) { + // note1 format has no relay hints + quotes.push(QuoteRef::Event { + id: *id.as_bytes(), + bech32: Some(bech32_str.to_string()), + relays: vec![], + }); + } + } + Mention::Note(_note_ref) => { + let bech32_str = block.as_str(); + // note1 format has no relay hints + if let Ok(Nip19::EventId(id)) = Nip19::from_bech32(bech32_str) { + quotes.push(QuoteRef::Event { + id: *id.as_bytes(), + bech32: Some(bech32_str.to_string()), + relays: vec![], + }); + } + } + // naddr mentions - articles (30023/30024) and highlights (9802) + Mention::Addr(_) => { + let bech32_str = block.as_str(); + if let Ok(Nip19::Coordinate(coord)) = Nip19::from_bech32(bech32_str) { + let kind = coord.kind.as_u16(); + if kind == 30023 || kind == 30024 || kind == 9802 { + let addr = format!( + "{}:{}:{}", + kind, + coord.public_key.to_hex(), + coord.identifier ); + quotes.push(QuoteRef::Article { + addr, + bech32: Some(bech32_str.to_string()), + relays: coord.relays, + }); } - }; + } } + _ => {} + } + } + + quotes +} + +/// Extracts quote references from q tags (NIP-18 quote reposts). +fn extract_quote_refs_from_tags(note: &Note) -> Vec { + use nostr_sdk::prelude::Nip19; + + let mut quotes = Vec::new(); + + for tag in note.tags() { + if tag.get_str(0) != Some("q") { + continue; + } + + let Some(value) = tag.get_str(1) else { + continue; }; + let trimmed = value.trim(); + + // Optional relay hint in third element of q tag + let tag_relay_hint: Option = tag + .get_str(2) + .filter(|s| !s.is_empty()) + .and_then(|s| RelayUrl::parse(s).ok()); + + // Try nevent/note bech32 + if trimmed.starts_with("nevent1") || trimmed.starts_with("note1") { + if let Ok(nip19) = Nip19::from_bech32(trimmed) { + match nip19 { + Nip19::Event(ev) => { + // Combine relays from nevent with q tag relay hint + let mut relays: Vec = ev.relays.to_vec(); + if let Some(hint) = &tag_relay_hint { + if !relays.contains(hint) { + relays.push(hint.clone()); + } + } + quotes.push(QuoteRef::Event { + id: *ev.event_id.as_bytes(), + bech32: Some(trimmed.to_owned()), + relays, + }); + continue; + } + Nip19::EventId(id) => { + quotes.push(QuoteRef::Event { + id: *id.as_bytes(), + bech32: Some(trimmed.to_owned()), + relays: tag_relay_hint.clone().into_iter().collect(), + }); + continue; + } + _ => {} + } + } + } + + // Try naddr bech32 + if trimmed.starts_with("naddr1") { + if let Ok(Nip19::Coordinate(coord)) = Nip19::from_bech32(trimmed) { + let addr = format!( + "{}:{}:{}", + coord.kind.as_u16(), + coord.public_key.to_hex(), + coord.identifier + ); + // Combine relays from naddr with q tag relay hint + let mut relays = coord.relays; + if let Some(hint) = &tag_relay_hint { + if !relays.contains(hint) { + relays.push(hint.clone()); + } + } + quotes.push(QuoteRef::Article { + addr, + bech32: Some(trimmed.to_owned()), + relays, + }); + continue; + } + } + + // Try article address format + if trimmed.starts_with("30023:") || trimmed.starts_with("30024:") { + quotes.push(QuoteRef::Article { + addr: trimmed.to_owned(), + bech32: None, + relays: tag_relay_hint.into_iter().collect(), + }); + continue; + } + + // Try hex event ID + if let Ok(bytes) = hex::decode(trimmed) { + if let Ok(id) = bytes.try_into() { + quotes.push(QuoteRef::Event { + id, + bech32: None, + relays: tag_relay_hint.into_iter().collect(), + }); + } + } + } + + quotes +} + +/// Collects all quote refs from a note (q tags + inline mentions). +pub fn collect_all_quote_refs(ndb: &Ndb, txn: &Transaction, note: &Note) -> Vec { + let mut refs = extract_quote_refs_from_tags(note); + + if let Some(blocks) = note.key().and_then(|k| ndb.get_blocks_by_key(txn, k).ok()) { + let inline = extract_quote_refs_from_content(note, &blocks); + // Deduplicate - only add inline refs not already in q tags + for r in inline { + if !refs.contains(&r) { + refs.push(r); + } + } } + + refs +} + +/// Looks up an article by address (kind:pubkey:d-tag) and returns the note key + optional title. +fn lookup_article_by_addr( + ndb: &Ndb, + txn: &Transaction, + addr: &str, +) -> Option<(NoteKey, Option)> { + let parts: Vec<&str> = addr.splitn(3, ':').collect(); + if parts.len() < 3 { + return None; + } + + let kind: u64 = parts[0].parse().ok()?; + let pubkey_bytes = hex::decode(parts[1]).ok()?; + let pubkey: [u8; 32] = pubkey_bytes.try_into().ok()?; + let d_identifier = parts[2]; + + let filter = Filter::new().authors([&pubkey]).kinds([kind]).build(); + let results = ndb.query(txn, &[filter], 10).ok()?; + + for result in results { + let mut found_d_match = false; + let mut title = None; + + for tag in result.note.tags() { + let tag_name = tag.get_str(0)?; + match tag_name { + "d" => { + if tag.get_str(1) == Some(d_identifier) { + found_d_match = true; + } + } + "title" => { + if let Some(t) = tag.get_str(1) { + if !t.trim().is_empty() { + title = Some(t.to_owned()); + } + } + } + _ => {} + } + } + + if found_d_match { + return Some((result.note_key, title)); + } + } + + None +} + +/// Builds a link URL for a quote reference. +fn build_quote_link(quote_ref: &QuoteRef) -> String { + use nostr_sdk::prelude::{Coordinate, EventId, Kind}; + + match quote_ref { + QuoteRef::Event { id, bech32, .. } => { + if let Some(b) = 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, .. } => { + if let Some(b) = bech32 { + return format!("/{}", b); + } + let parts: Vec<&str> = addr.splitn(3, ':').collect(); + if parts.len() >= 3 { + if let Ok(kind) = parts[0].parse::() { + if let Ok(pubkey) = PublicKey::from_hex(parts[1]) { + let coordinate = + Coordinate::new(Kind::from(kind), pubkey).identifier(parts[2]); + if let Ok(naddr) = coordinate.to_bech32() { + return format!("/{}", naddr); + } + } + } + } + } + } + "#".to_string() +} + +/// Builds embedded quote HTML for referenced events. +fn build_embedded_quotes_html(ndb: &Ndb, txn: &Transaction, quote_refs: &[QuoteRef]) -> String { + use nostrdb::NoteReply; + + if quote_refs.is_empty() { + return String::new(); + } + + let mut quotes_html = String::new(); + + for quote_ref in quote_refs { + let quoted_note = match quote_ref { + QuoteRef::Event { id, .. } => match ndb.get_note_by_id(txn, id) { + Ok(note) => note, + Err(_) => continue, + }, + QuoteRef::Article { addr, .. } => match lookup_article_by_addr(ndb, txn, addr) { + Some((note_key, _title)) => match ndb.get_note_by_key(txn, note_key) { + Ok(note) => note, + Err(_) => continue, + }, + None => continue, + }, + }; + + // Get author profile (filter empty strings for proper fallback) + let profile_info = ndb + .get_profile_by_pubkey(txn, quoted_note.pubkey()) + .ok() + .and_then(|rec| { + rec.record().profile().map(|p| { + let display_name = p + .display_name() + .filter(|s| !s.is_empty()) + .or_else(|| p.name().filter(|s| !s.is_empty())) + .map(|n| n.to_owned()); + let username = p + .name() + .filter(|s| !s.is_empty()) + .map(|n| format!("@{}", n)); + let pfp_url = p.picture().filter(|s| !s.is_empty()).map(|s| s.to_owned()); + QuoteProfileInfo { + display_name, + username, + pfp_url, + } + }) + }) + .unwrap_or(QuoteProfileInfo { + display_name: None, + username: None, + pfp_url: None, + }); + + let display_name = profile_info + .display_name + .unwrap_or_else(|| "nostrich".to_string()); + let display_name_html = html_escape::encode_text(&display_name); + let username_html = profile_info + .username + .map(|u| { + format!( + r#" {}"#, + html_escape::encode_text(&u) + ) + }) + .unwrap_or_default(); + + let pfp_html = profile_info + .pfp_url + .filter(|url| !url.trim().is_empty()) + .map(|url| { + let pfp_attr = html_escape::encode_double_quoted_attribute(&url); + format!( + r#""#, + pfp_attr + ) + }) + .unwrap_or_else(|| { + r#""# + .to_string() + }); + + let relative_time = format_relative_time(quoted_note.created_at()); + let time_html = html_escape::encode_text(&relative_time); + + // Detect reply using nostrdb's NoteReply + let reply_html = NoteReply::new(quoted_note.tags()) + .reply() + .and_then(|reply_ref| ndb.get_note_by_id(txn, reply_ref.id).ok()) + .and_then(|parent| { + get_profile_display_name( + ndb.get_profile_by_pubkey(txn, parent.pubkey()) + .ok() + .as_ref(), + ) + .map(|name| format!("@{}", name)) + }) + .map(|name| { + format!( + r#"
Replying to {}
"#, + html_escape::encode_text(&name) + ) + }) + .unwrap_or_default(); + + // For articles, we use a special card layout with image, title, summary, word count + let (content_preview, is_truncated, type_indicator, content_class, article_card) = + match quoted_note.kind() { + // For articles, extract metadata and build card layout + 30023 | 30024 => { + let mut title: Option<&str> = None; + let mut image: Option<&str> = None; + let mut summary: Option<&str> = None; + + for tag in quoted_note.tags() { + let mut iter = tag.into_iter(); + let Some(tag_name) = iter.next().and_then(|n| n.variant().str()) else { + continue; + }; + let tag_value = iter.next().and_then(|n| n.variant().str()); + match tag_name { + "title" => title = tag_value, + "image" => image = tag_value.filter(|s| !s.is_empty()), + "summary" => summary = tag_value.filter(|s| !s.is_empty()), + _ => {} + } + } + + // Calculate word count + let word_count = quoted_note.content().split_whitespace().count(); + let word_count_text = format!("{} Words", word_count); + + // Build article card HTML + let title_text = title.unwrap_or("Untitled article"); + let title_html = html_escape::encode_text(title_text); + + let image_html = image + .map(|url| { + let url_attr = html_escape::encode_double_quoted_attribute(url); + format!( + r#""#, + url_attr + ) + }) + .unwrap_or_default(); + + let summary_html = summary + .map(|s| { + let text = html_escape::encode_text(abbreviate(s, 150)); + format!( + r#"
{}
"#, + text + ) + }) + .unwrap_or_default(); + + let draft_class = if quoted_note.kind() == 30024 { + " damus-embedded-article-draft" + } else { + "" + }; + + let card_html = format!( + r#"{image}
{title}
{summary}
{words}
"#, + image = image_html, + draft = draft_class, + title = title_html, + summary = summary_html, + words = word_count_text + ); + + ( + String::new(), + false, + "", + " damus-embedded-quote-article", + Some(card_html), + ) + } + // For highlights, use left border styling (no tag needed) + 9802 => { + let full_content = quoted_note.content(); + let content = abbreviate(full_content, 200); + let truncated = content.len() < full_content.len(); + ( + content.to_string(), + truncated, + "", + " damus-embedded-quote-highlight", + None, + ) + } + _ => { + let full_content = quoted_note.content(); + let content = abbreviate(full_content, 280); + let truncated = content.len() < full_content.len(); + (content.to_string(), truncated, "", "", None) + } + }; + let content_html = html_escape::encode_text(&content_preview).replace("\n", " "); + + // Build link to quoted note + let link = build_quote_link(quote_ref); + + // For articles, use card layout; for other types, use regular content layout + let body_html = if let Some(card) = article_card { + card + } else { + let show_more = if is_truncated { + r#" Show more"# + } else { + "" + }; + format!( + r#"
{content}{showmore}
"#, + class = content_class, + content = content_html, + showmore = show_more + ) + }; + + let _ = write!( + quotes_html, + r#" +
+ {pfp} + {name}{username} + · {time} + {type_indicator} +
+ {reply} + {body} +
"#, + link = link, + content_class = content_class, + pfp = pfp_html, + name = display_name_html, + username = username_html, + time = time_html, + type_indicator = type_indicator, + reply = reply_html, + body = body_html + ); + } + + if quotes_html.is_empty() { + return String::new(); + } + + format!( + r#"
{}
"#, + quotes_html + ) } struct Profile<'a> { @@ -334,11 +1039,22 @@ impl<'a> Profile<'a> { } fn author_display_html(profile: Option<&ProfileRecord<'_>>) -> String { - let profile_name_raw = profile + let profile_name_raw = get_profile_display_name(profile).unwrap_or("nostrich"); + html_escape::encode_text(profile_name_raw).into_owned() +} + +/// Returns the @username handle markup if available, empty string otherwise. +/// Uses profile.name() (the NIP-01 "name" field) as the handle. +fn author_handle_html(profile: Option<&ProfileRecord<'_>>) -> String { + profile .and_then(|p| p.record().profile()) .and_then(|p| p.name()) - .unwrap_or("nostrich"); - html_escape::encode_text(profile_name_raw).into_owned() + .filter(|name| !name.is_empty()) + .map(|name| { + let escaped = html_escape::encode_text(name); + format!(r#"@{}"#, escaped) + }) + .unwrap_or_default() } fn build_note_content_html( @@ -350,16 +1066,18 @@ fn build_note_content_html( relays: &[RelayUrl], ) -> String { let mut body_buf = Vec::new(); - if let Some(blocks) = note + let blocks = note .key() - .and_then(|nk| app.ndb.get_blocks_by_key(txn, nk).ok()) - { - render_note_content(&mut body_buf, note, &blocks); + .and_then(|nk| app.ndb.get_blocks_by_key(txn, nk).ok()); + + if let Some(ref blocks) = blocks { + render_note_content(&mut body_buf, note, blocks, &app.ndb, txn); } else { let _ = write!(body_buf, "{}", html_escape::encode_text(note.content())); } let author_display = author_display_html(profile.record.as_ref()); + let author_handle = author_handle_html(profile.record.as_ref()); let npub = profile.key.to_bech32().unwrap(); let note_body = String::from_utf8(body_buf).unwrap_or_default(); let pfp_attr = pfp_url_attr( @@ -367,12 +1085,31 @@ 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 + let mut quote_refs = extract_quote_refs_from_tags(note); + if let Some(ref blocks) = blocks { + for content_ref in extract_quote_refs_from_content(note, blocks) { + // Deduplicate by event_id or article_addr + let is_dup = quote_refs + .iter() + .any(|existing| match (existing, &content_ref) { + (QuoteRef::Event { id: a, .. }, QuoteRef::Event { id: b, .. }) => a == b, + (QuoteRef::Article { addr: a, .. }, QuoteRef::Article { addr: b, .. }) => { + a == b + } + _ => false, + }); + if !is_dup { + quote_refs.push(content_ref); + } + } + } + let quotes_html = build_embedded_quotes_html(&app.ndb, txn, "e_refs); + format!( r#""#, base = base_url, pfp = pfp_attr, author = author_display, + handle = author_handle, ts = timestamp_attr, - body = note_body + body = note_body, + quotes = quotes_html ) } @@ -407,6 +1148,7 @@ fn build_article_content_html( summary_html: Option<&str>, article_body_html: &str, topics: &[String], + is_draft: bool, base_url: &str, ) -> String { let pfp_attr = pfp_url_attr( @@ -415,6 +1157,7 @@ fn build_article_content_html( ); let timestamp_attr = timestamp_value.to_string(); let author_display = author_display_html(profile.record.as_ref()); + let author_handle = author_handle_html(profile.record.as_ref()); let hero_markup = hero_image .filter(|url| !url.is_empty()) @@ -448,16 +1191,24 @@ fn build_article_content_html( topics_markup.push_str(""); } + // Draft badge for unpublished articles (kind:30024) + let draft_markup = if is_draft { + r#"DRAFT"# + } else { + "" + }; + format!( r#"
{author} profile picture
{author}
+ {handle}
-

{title}

+

{title}{draft}

{hero} {summary} {topics} @@ -465,8 +1216,10 @@ fn build_article_content_html(
"#, pfp = pfp_attr, author = author_display, + handle = author_handle, ts = timestamp_attr, title = article_title_html, + draft = draft_markup, hero = hero_markup, summary = summary_markup, topics = topics_markup, @@ -474,6 +1227,169 @@ fn build_article_content_html( ) } +/// Builds HTML for a NIP-84 highlight (kind:9802). +fn build_highlight_content_html( + profile: &Profile<'_>, + base_url: &str, + timestamp_value: u64, + highlight_text_html: &str, + context_html: Option<&str>, + comment_html: Option<&str>, + source_markup: &str, +) -> String { + let author_display = author_display_html(profile.record.as_ref()); + let author_handle = author_handle_html(profile.record.as_ref()); + let pfp_attr = pfp_url_attr( + profile.record.as_ref().and_then(|r| r.record().profile()), + base_url, + ); + let timestamp_attr = timestamp_value.to_string(); + + let context_markup = context_html + .filter(|ctx| !ctx.is_empty()) + .map(|ctx| format!(r#"
…{ctx}…
"#)) + .unwrap_or_default(); + + let comment_markup = comment_html + .filter(|c| !c.is_empty()) + .map(|c| format!(r#"
{c}
"#)) + .unwrap_or_default(); + + format!( + r#"
+
+ {author} profile picture +
+
{author}
+ {handle} + +
+
+ {comment} +
{highlight}
+ {context} + {source} +
"#, + pfp = pfp_attr, + author = author_display, + handle = author_handle, + ts = timestamp_attr, + comment = comment_markup, + highlight = highlight_text_html, + context = context_markup, + source = source_markup + ) +} + +/// Builds source attribution markup for a highlight. +fn build_highlight_source_markup(ndb: &Ndb, txn: &Transaction, meta: &HighlightMetadata) -> String { + // Priority: article > note > URL + + // Case 1: Source is a nostr article (a tag) + if let Some(addr) = &meta.source_article_addr { + if let Some((note_key, title)) = lookup_article_by_addr(ndb, txn, addr) { + let author_name = ndb.get_note_by_key(txn, note_key).ok().and_then(|note| { + get_profile_display_name( + ndb.get_profile_by_pubkey(txn, note.pubkey()).ok().as_ref(), + ) + .map(|s| s.to_owned()) + }); + + return build_article_source_link(addr, title.as_deref(), author_name.as_deref()); + } + } + + // Case 2: Source is a nostr note (e tag) + if let Some(event_id) = &meta.source_event_id { + return build_note_source_link(event_id); + } + + // Case 3: Source is a web URL (r tag) + if let Some(url) = &meta.source_url { + return build_url_source_link(url); + } + + String::new() +} + +/// Builds source link for an article reference. +fn build_article_source_link(addr: &str, title: Option<&str>, author: Option<&str>) -> String { + use nostr_sdk::prelude::{Coordinate, Kind}; + + let parts: Vec<&str> = addr.splitn(3, ':').collect(); + if parts.len() < 3 { + return String::new(); + } + + let Ok(kind) = parts[0].parse::() else { + return String::new(); + }; + let Ok(pubkey) = PublicKey::from_hex(parts[1]) else { + return String::new(); + }; + + let coordinate = Coordinate::new(Kind::from(kind), pubkey).identifier(parts[2]); + let Ok(naddr) = coordinate.to_bech32() else { + return String::new(); + }; + + let display_text = match (title, author) { + (Some(t), Some(a)) => format!( + "{} by {}", + html_escape::encode_text(t), + html_escape::encode_text(a) + ), + (Some(t), None) => html_escape::encode_text(t).into_owned(), + (None, Some(a)) => format!("Article by {}", html_escape::encode_text(a)), + (None, None) => abbrev_str(&naddr).to_string(), + }; + + let href_raw = format!("/{naddr}"); + let href = html_escape::encode_double_quoted_attribute(&href_raw); + format!( + r#"
"#, + href = href, + display = display_text + ) +} + +/// Builds source link for a note reference. +fn build_note_source_link(event_id: &[u8; 32]) -> String { + use nostr_sdk::prelude::EventId; + + let Ok(id) = EventId::from_slice(event_id) 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); + format!( + r#"
From note: {abbrev}
"#, + href = href, + abbrev = abbrev_str(&nevent) + ) +} + +/// Builds source link for a web URL. +fn build_url_source_link(url: &str) -> String { + let domain = url + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or(url); + + let href = html_escape::encode_double_quoted_attribute(url); + let domain_html = html_escape::encode_text(domain); + + format!( + r#"
From: {domain}
"#, + href = href, + domain = domain_html + ) +} + const LOCAL_TIME_SCRIPT: &str = r#"