commit a4f6400693f8d645d8a9a71dc983eaf4bf4a5ef8
parent a0a2a5126fa01aa09be1f8ebfcf7221e4c968640
Author: William Casarin <jb55@jb55.com>
Date: Thu, 18 Dec 2025 09:05:30 -0800
Merge relay pool + design improvements by elsat #37
alltheseas (14):
Add persistent relay pool and richer npub profile rendering
Address feedback on relay queries and mobile layout
Align notecrumbs frontend with Damus styling
Avoid stale LMDB transactions when hydrating render data
Document relay discovery sources
Expose relay pool metrics via Prometheus
Improve profile feed freshness and ordering
Improve relay resiliency with metrics and discovery
Load default avatar from embedded bytes
Refine relay pool connections and profile experience
Remove unused profile image cache plumbing
Restore npub profile styling and copy interaction
Stabilize address lookup test against nostrdb races
Track embedded font assets
Diffstat:
11 files changed, 2224 insertions(+), 237 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -4,7 +4,6 @@ data.mdb
lock.mdb
.build-result
.buildcmd
-/fonts
TODO.bak
tags
perf.data
diff --git a/Cargo.lock b/Cargo.lock
@@ -178,6 +178,12 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
@@ -756,6 +762,12 @@ dependencies = [
]
[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -821,6 +833,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -961,6 +988,18 @@ dependencies = [
]
[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
name = "gif"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -999,7 +1038,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
- "http",
+ "http 1.3.1",
"indexmap",
"slab",
"tokio",
@@ -1020,6 +1059,15 @@ dependencies = [
[[package]]
name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
@@ -1042,6 +1090,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1108,6 +1162,17 @@ dependencies = [
[[package]]
name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565"
@@ -1119,12 +1184,23 @@ dependencies = [
[[package]]
name = "http-body"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
+dependencies = [
+ "bytes",
+ "http 0.2.12",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.3.1",
]
[[package]]
@@ -1135,8 +1211,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
- "http",
- "http-body",
+ "http 1.3.1",
+ "http-body 1.0.1",
"pin-project-lite",
]
@@ -1154,6 +1230,29 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
+version = "0.14.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2 0.5.10",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
+[[package]]
+name = "hyper"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
@@ -1163,8 +1262,8 @@ dependencies = [
"futures-channel",
"futures-core",
"h2",
- "http",
- "http-body",
+ "http 1.3.1",
+ "http-body 1.0.1",
"httparse",
"httpdate",
"itoa",
@@ -1176,6 +1275,19 @@ dependencies = [
]
[[package]]
+name = "hyper-tls"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
+dependencies = [
+ "bytes",
+ "hyper 0.14.32",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+]
+
+[[package]]
name = "hyper-util"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1186,14 +1298,14 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
- "http",
- "http-body",
- "hyper",
+ "http 1.3.1",
+ "http-body 1.0.1",
+ "hyper 1.7.0",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2",
+ "socket2 0.6.1",
"system-configuration",
"tokio",
"tower-service",
@@ -1541,6 +1653,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
+name = "metrics"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fde3af1a009ed76a778cb84fdef9e7dbbdf5775ae3e4cc1f434a6a307f6f76c5"
+dependencies = [
+ "ahash",
+ "metrics-macros",
+ "portable-atomic",
+]
+
+[[package]]
+name = "metrics"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56d05972e8cbac2671e85aa9d04d9160d193f8bebd1a5c1a2f4542c62e65d1d0"
+dependencies = [
+ "ahash",
+ "portable-atomic",
+]
+
+[[package]]
+name = "metrics-exporter-prometheus"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bf4e7146e30ad172c42c39b3246864bd2d3c6396780711a1baf749cfe423e21"
+dependencies = [
+ "base64 0.21.7",
+ "hyper 0.14.32",
+ "hyper-tls",
+ "indexmap",
+ "ipnet",
+ "metrics 0.22.4",
+ "metrics-util",
+ "quanta",
+ "thiserror 1.0.69",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "metrics-macros"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38b4faf00617defe497754acde3024865bc143d44a86799b24e191ecff91354f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.107",
+]
+
+[[package]]
+name = "metrics-util"
+version = "0.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b07a5eb561b8cbc16be2d216faf7757f9baf3bfb94dbb0fae3df8387a5bb47f"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "metrics 0.22.4",
+ "num_cpus",
+ "quanta",
+ "sketches-ddsketch",
+]
+
+[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1584,6 +1762,23 @@ dependencies = [
]
[[package]]
+name = "native-tls"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
name = "negentropy"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1631,7 +1826,7 @@ dependencies = [
"cbc",
"chacha20",
"chacha20poly1305",
- "getrandom",
+ "getrandom 0.2.16",
"instant",
"negentropy 0.3.1",
"negentropy 0.4.3",
@@ -1719,12 +1914,13 @@ dependencies = [
"egui_skia",
"hex",
"html-escape",
- "http",
+ "http 1.3.1",
"http-body-util",
- "hyper",
+ "hyper 1.7.0",
"hyper-util",
"image",
- "lru",
+ "metrics 0.21.1",
+ "metrics-exporter-prometheus",
"nostr",
"nostr-sdk",
"nostrdb",
@@ -1755,6 +1951,16 @@ dependencies = [
]
[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1767,6 +1973,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
+name = "openssl"
+version = "0.10.74"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
+dependencies = [
+ "bitflags 2.10.0",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.107",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.110"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1902,6 +2152,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1926,6 +2182,12 @@ dependencies = [
]
[[package]]
+name = "portable-atomic"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
+
+[[package]]
name = "potential_utf"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1990,6 +2252,21 @@ dependencies = [
]
[[package]]
+name = "quanta"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
+dependencies = [
+ "crossbeam-utils",
+ "libc",
+ "once_cell",
+ "raw-cpuid",
+ "wasi",
+ "web-sys",
+ "winapi",
+]
+
+[[package]]
name = "quote"
version = "1.0.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1999,6 +2276,12 @@ dependencies = [
]
[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2025,7 +2308,16 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
- "getrandom",
+ "getrandom 0.2.16",
+]
+
+[[package]]
+name = "raw-cpuid"
+version = "11.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
+dependencies = [
+ "bitflags 2.10.0",
]
[[package]]
@@ -2123,7 +2415,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
- "getrandom",
+ "getrandom 0.2.16",
"libc",
"untrusted",
"windows-sys 0.52.0",
@@ -2181,9 +2473,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.34"
+version = "0.23.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7"
+checksum = "751e04a496ca00bb97a5e043158d23d66b5aabf2e1d5aa2a0aaebb1aafe6f82c"
dependencies = [
"log",
"once_cell",
@@ -2236,6 +2528,15 @@ dependencies = [
]
[[package]]
+name = "schannel"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2275,6 +2576,29 @@ dependencies = [
]
[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.10.0",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2398,6 +2722,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
+name = "sketches-ddsketch"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c"
+
+[[package]]
name = "skia-bindings"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2440,6 +2770,16 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "socket2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
@@ -2569,6 +2909,19 @@ dependencies = [
]
[[package]]
+name = "tempfile"
+version = "3.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
+dependencies = [
+ "fastrand",
+ "getrandom 0.3.4",
+ "once_cell",
+ "rustix 1.1.2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2701,7 +3054,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
- "socket2",
+ "socket2 0.6.1",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -2718,6 +3071,16 @@ dependencies = [
]
[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2872,7 +3235,7 @@ dependencies = [
"byteorder",
"bytes",
"data-encoding",
- "http",
+ "http 1.3.1",
"httparse",
"log",
"rand",
@@ -3005,6 +3368,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3026,6 +3395,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
+name = "wasip2"
+version = "1.0.1+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
name = "wasm-bindgen"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3156,6 +3534,28 @@ dependencies = [
]
[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3362,6 +3762,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
+name = "wit-bindgen"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
+
+[[package]]
name = "writeable"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -26,10 +26,11 @@ egui_skia = { git = "https://github.com/jb55/egui_skia.git", rev = "6205d63e751d
#egui_skia = { path = "/home/jb55/dev/github/lucasmerlin/egui_skia", features = ["cpu_fix"] }
skia-safe = "0.58.0"
image = "0.24.7"
-lru = "0.12.1"
bytes = "1.5.0"
http = "1.0.0"
html-escape = "0.2.13"
ammonia = "4.0"
pulldown-cmark = "0.9"
serde_json = "*"
+metrics = "0.21"
+metrics-exporter-prometheus = "0.13"
diff --git a/README.md b/README.md
@@ -26,3 +26,8 @@ Very alpha. The design is still a bit rough, but getting there:
<img style="width: 600px; height: 300px" src="https://damus.io/nevent1qqstj0wgdgplzypp5fjlg5vdr9mcex5me7elhcvh2trk0836y69q9cgsn6gzr.png">
+## Relay discovery & metrics
+
+- Notecrumbs keeps long-lived relay connections and now learns new relays from every event it ingests. Relay list (`kind:10002`) events and contact lists (`kind:3`) are parsed for `r`/`relays` tags as well as per-contact relay hints, and the pool deduplicates and connects to any valid URLs it sees.
+- Per-request fetch loops feed those hints straight into the shared pool, so visiting a profile helps warm future requests that need the same relays.
+- Relay pool health counters (ensure calls, added relays, connect successes/failures, and active relay count) are exposed via Prometheus at `http://127.0.0.1:3000/metrics`, and a mirrored summary is logged every 60 seconds.
diff --git a/assets/damus.css b/assets/damus.css
@@ -0,0 +1,462 @@
+@font-face {
+ font-family: "PoetsenOne";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url("/fonts/PoetsenOne-Regular.ttf") format("truetype");
+}
+
+:root {
+ color-scheme: dark;
+ --damus-bg: #060114;
+ --damus-gradient: radial-gradient(circle at 18% 20%, rgba(248, 79, 255, 0.28) 0%, rgba(34, 5, 66, 0.82) 45%, rgba(6, 1, 20, 0.96) 100%);
+ --damus-card-bg: rgba(22, 10, 38, 0.82);
+ --damus-card-border: rgba(255, 255, 255, 0.08);
+ --damus-text: #f5f2ff;
+ --damus-muted: rgba(226, 215, 255, 0.72);
+ --damus-accent: #bd66ff;
+ --damus-accent-strong: #ff59d5;
+ --damus-highlight: rgba(189, 102, 255, 0.16);
+ --damus-font: "Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ min-height: 100%;
+}
+
+body {
+ font-family: var(--damus-font);
+ background: var(--damus-gradient), var(--damus-bg);
+ background-attachment: fixed;
+ color: var(--damus-text);
+ line-height: 1.6;
+ letter-spacing: -0.01em;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ margin: 0;
+ font-weight: 600;
+ color: #ffffff;
+ letter-spacing: -0.02em;
+}
+
+p {
+ margin: 0 0 1.1em;
+}
+
+p:last-child {
+ margin-bottom: 0;
+}
+
+a {
+ color: var(--damus-accent);
+ text-decoration: none;
+ transition: color 160ms ease;
+}
+
+a:hover {
+ color: var(--damus-accent-strong);
+ text-decoration: underline;
+}
+
+.damus-app {
+ width: min(960px, 100%);
+ margin: 0 auto;
+ padding: 48px 16px 72px;
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+}
+
+@media (min-width: 768px) {
+ .damus-app {
+ padding: 64px 32px 96px;
+ }
+}
+
+.damus-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+}
+
+.damus-logo-link {
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+}
+
+.damus-logo-image {
+ width: clamp(36px, 7vw, 48px);
+ height: clamp(36px, 7vw, 48px);
+ border-radius: 12px;
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
+}
+
+.damus-header-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+}
+
+.damus-link {
+ color: var(--damus-muted);
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.damus-link:hover {
+ color: #ffffff;
+}
+
+.damus-cta {
+ background: linear-gradient(135deg, var(--damus-accent), var(--damus-accent-strong));
+ color: #090118;
+ border-radius: 999px;
+ padding: 0.55rem 1.35rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ box-shadow: 0 12px 34px rgba(189, 102, 255, 0.35);
+ transition: transform 160ms ease, box-shadow 160ms ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.damus-cta:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 18px 44px rgba(189, 102, 255, 0.45);
+ text-decoration: none;
+}
+
+
+.damus-main {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.damus-supporting {
+ font-size: clamp(1rem, 2.2vw, 1.2rem);
+ color: var(--damus-muted);
+ max-width: 720px;
+}
+
+.damus-card {
+ background: var(--damus-card-bg);
+ border: 1px solid var(--damus-card-border);
+ border-radius: 28px;
+ padding: clamp(1.75rem, 3vw, 2.5rem);
+ box-shadow: 0 25px 60px rgba(5, 0, 21, 0.5);
+ backdrop-filter: blur(24px);
+ display: flex;
+ flex-direction: column;
+ gap: 1.75rem;
+}
+
+.damus-section {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+.damus-section-title {
+ font-size: clamp(1.3rem, 3vw, 1.75rem);
+ font-weight: 700;
+ color: #ffffff;
+}
+
+.damus-profile-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ font-size: 0.95rem;
+ color: var(--damus-muted);
+}
+
+.damus-profile-meta-row {
+ display: flex;
+ gap: 0.65rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.damus-meta-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 999px;
+ background: rgba(189, 102, 255, 0.18);
+ color: var(--damus-accent-strong);
+}
+
+.damus-meta-icon svg {
+ width: 18px;
+ height: 18px;
+}
+
+.damus-profile-meta-row a {
+ color: inherit;
+ text-decoration: none;
+ word-break: break-all;
+}
+
+.damus-profile-meta-row a:hover {
+ color: #ffffff;
+ text-decoration: underline;
+}
+
+.damus-sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.damus-note-header,
+.damus-profile-header {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+}
+
+.damus-note-avatar {
+ width: 64px;
+ height: 64px;
+ border-radius: 24px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ object-fit: cover;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.damus-note-author {
+ font-weight: 700;
+ font-size: 1.15rem;
+ color: #ffffff;
+}
+
+.damus-note-time {
+ font-size: 0.9rem;
+ color: var(--damus-muted);
+ display: block;
+ margin-top: 4px;
+}
+
+.damus-note-body {
+ font-size: 1.05rem;
+ line-height: 1.7;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+
+.damus-note-body img {
+ max-width: 100%;
+ border-radius: 20px;
+ margin: 1.25rem 0;
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
+}
+
+.damus-note-body ul,
+.damus-note-body ol {
+ padding-left: 1.25rem;
+ margin: 0 0 1.1em;
+}
+
+.damus-note-body blockquote {
+ margin: 0 0 1.1em;
+ padding-left: 1rem;
+ border-left: 2px solid rgba(255, 255, 255, 0.25);
+ color: rgba(255, 255, 255, 0.75);
+}
+
+.damus-note-body code {
+ background: rgba(255, 255, 255, 0.08);
+ padding: 0.2rem 0.45rem;
+ border-radius: 8px;
+ font-family: "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
+ font-size: 0.9rem;
+}
+
+.damus-note-body pre {
+ background: rgba(0, 0, 0, 0.35);
+ padding: 1rem;
+ border-radius: 16px;
+ margin: 1.25rem 0;
+ overflow-x: auto;
+}
+
+.damus-article-title {
+ font-family: "PoetsenOne", var(--damus-font);
+ font-size: clamp(2rem, 4vw, 3rem);
+ margin: 0;
+}
+
+.damus-article-hero {
+ max-width: 100%;
+ border-radius: 28px;
+ box-shadow: 0 24px 45px rgba(0, 0, 0, 0.35);
+}
+
+.damus-article-summary {
+ font-size: 1.1rem;
+ color: var(--damus-muted);
+ margin: 0;
+}
+
+.damus-article-topics {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.damus-article-topic {
+ background: rgba(189, 102, 255, 0.12);
+ color: #ffffff;
+ padding: 0.35rem 0.75rem;
+ border-radius: 999px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+
+.damus-profile-card {
+ gap: 1.5rem;
+}
+
+.damus-profile-names {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.damus-profile-handle {
+ font-size: 1rem;
+ color: var(--damus-muted);
+}
+
+.damus-profile-about {
+ font-size: 1.05rem;
+ line-height: 1.7;
+ color: var(--damus-muted);
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+
+.damus-relays {
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ padding-top: 1.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+}
+
+.damus-relays summary {
+ cursor: pointer;
+ font-weight: 600;
+ color: #ffffff;
+ list-style: none;
+}
+
+.damus-relays summary::-webkit-details-marker {
+ display: none;
+}
+
+.damus-relay-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ margin: 0.75rem 0 0;
+ padding: 0;
+ list-style: none;
+ font-size: 0.9rem;
+ color: var(--damus-muted);
+}
+
+.damus-relay-role {
+ margin-left: 0.35rem;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: rgba(255, 255, 255, 0.55);
+}
+
+.damus-footer {
+ color: var(--damus-muted);
+ font-size: 0.9rem;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ text-align: center;
+ justify-content: center;
+ align-items: center;
+}
+
+.damus-footer a {
+ color: inherit;
+}
+
+.damus-footer a:hover {
+ color: #ffffff;
+}
+
+.muted-link {
+ color: var(--damus-muted);
+}
+
+.muted-link:hover {
+ color: #ffffff;
+}
+
+.muted {
+ color: var(--damus-muted);
+}
+
+@media (max-width: 640px) {
+ .damus-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .damus-header-actions {
+ width: 100%;
+ align-items: flex-start;
+ }
+
+ .damus-cta {
+ width: 100%;
+ }
+
+ .damus-card {
+ border-radius: 22px;
+ padding: 1.5rem;
+ }
+
+ .damus-note-avatar {
+ width: 56px;
+ height: 56px;
+ }
+}
diff --git a/assets/logo_icon.png b/assets/logo_icon.png
Binary files differ.
diff --git a/fonts/PoetsenOne-Regular.ttf b/fonts/PoetsenOne-Regular.ttf
Binary files differ.
diff --git a/src/html.rs b/src/html.rs
@@ -1,19 +1,59 @@
use crate::Error;
use crate::{
abbrev::{abbrev_str, abbreviate},
- render::{NoteAndProfileRenderData, ProfileRenderData},
+ render::{NoteAndProfileRenderData, ProfileRenderData, PROFILE_FEED_RECENT_LIMIT},
Notecrumbs,
};
use ammonia::Builder as HtmlSanitizer;
use http_body_util::Full;
use hyper::{body::Bytes, header, Request, Response, StatusCode};
-use nostr_sdk::prelude::{Nip19, ToBech32};
-use nostrdb::{BlockType, Blocks, Filter, Mention, Ndb, Note, Transaction};
+use nostr_sdk::prelude::{Nip19, PublicKey, ToBech32};
+use nostrdb::{BlockType, Blocks, Filter, Mention, Ndb, Note, NoteKey, Transaction};
use pulldown_cmark::{html, Options, Parser};
use std::fmt::Write as _;
use std::io::Write;
use std::str::FromStr;
+use tracing::warn;
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct RelayEntry {
+ url: String,
+ read: bool,
+ write: bool,
+}
+
+fn merge_relay_entry(relays: &mut Vec<RelayEntry>, url: &str, marker: Option<&str>) {
+ let cleaned_url = url.trim();
+ if cleaned_url.is_empty() {
+ return;
+ }
+
+ let (read, write) = marker
+ .map(|value| value.trim().to_ascii_lowercase())
+ .map(|value| match value.as_str() {
+ "read" => (true, false),
+ "write" => (false, true),
+ _ => (true, true),
+ })
+ .unwrap_or((true, true));
+
+ if let Some(existing) = relays.iter_mut().find(|entry| entry.url == cleaned_url) {
+ existing.read |= read;
+ existing.write |= write;
+ return;
+ }
+
+ relays.push(RelayEntry {
+ url: cleaned_url.to_string(),
+ read,
+ write,
+ });
+}
+
+const ICON_KEY_CIRCLE: &str = r#"<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.3058 6.37751C11.4643 7.01298 11.0775 7.65657 10.4421 7.81501C9.80661 7.97345 9.16302 7.58674 9.00458 6.95127C8.84614 6.3158 9.23285 5.67221 9.86831 5.51377C10.5038 5.35533 11.1474 5.74204 11.3058 6.37751Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18ZM10.98 10.0541C12.8102 9.59778 13.9381 7.80131 13.4994 6.04155C13.0606 4.28178 11.2213 3.22513 9.39116 3.68144C7.56101 4.13774 6.43306 5.93422 6.87182 7.69398C6.97647 8.11372 7.1608 8.49345 7.40569 8.8222L5.3739 12.0582C5.30459 12.1686 5.28324 12.3025 5.31477 12.4289L5.73708 14.1228C5.7691 14.2511 5.89912 14.3293 6.02751 14.2973L7.81697 13.8511C7.93712 13.8211 8.04101 13.7458 8.10686 13.641L10.295 10.1559C10.5216 10.1446 10.7509 10.1112 10.98 10.0541Z" fill="currentColor"/></svg>"#;
+const ICON_CONTACT_CIRCLE: &str = r#"<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18ZM11.6667 6.66667C11.6667 8.13943 10.4728 9.33333 9.00004 9.33333C7.52728 9.33333 6.33337 8.13943 6.33337 6.66667C6.33337 5.19391 7.52728 4 9.00004 4C10.4728 4 11.6667 5.19391 11.6667 6.66667ZM13.6667 12.3333C13.6667 13.2538 11.5774 14 9.00004 14C6.42271 14 4.33337 13.2538 4.33337 12.3333C4.33337 11.4129 6.42271 10.6667 9.00004 10.6667C11.5774 10.6667 13.6667 11.4129 13.6667 12.3333Z" fill="currentColor"/></svg>"#;
+const ICON_LINK_CIRCLE: &str = r#"<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18ZM10.5074 5.12274C10.7369 4.89317 11.1091 4.89317 11.3387 5.12274L12.8772 6.6612C13.1067 6.89077 13.1067 7.26298 12.8772 7.49256L10.9541 9.41563C10.7588 9.6109 10.7588 9.92748 10.9541 10.1227C11.1494 10.318 11.4659 10.318 11.6612 10.1227L13.5843 8.19966C14.2044 7.57957 14.2044 6.57419 13.5843 5.95409L12.0458 4.41563C11.4257 3.79554 10.4203 3.79554 9.80025 4.41563L7.87718 6.33871C7.68191 6.53397 7.68191 6.85055 7.87718 7.04582C8.07244 7.24108 8.38902 7.24108 8.58428 7.04582L10.5074 5.12274ZM11.0843 7.62274C11.2795 7.42748 11.2795 7.1109 11.0843 6.91563C10.889 6.72037 10.5724 6.72037 10.3772 6.91563L7.10794 10.1849C6.91268 10.3801 6.91268 10.6967 7.10794 10.892C7.30321 11.0872 7.61979 11.0872 7.81505 10.892L11.0843 7.62274ZM7.04582 8.5843C7.24108 8.38904 7.24108 8.07246 7.04582 7.8772C6.85055 7.68194 6.53397 7.68194 6.33871 7.8772L4.41563 9.80027C3.79554 10.4204 3.79554 11.4257 4.41563 12.0458L5.9541 13.5843C6.57419 14.2044 7.57957 14.2044 8.19966 13.5843L10.1227 11.6612C10.318 11.466 10.318 11.1494 10.1227 10.9541C9.92748 10.7589 9.6109 10.7589 9.41563 10.9541L7.49256 12.8772C7.26299 13.1068 6.89077 13.1068 6.6612 12.8772L5.12274 11.3387C4.89317 11.1092 4.89317 10.737 5.12274 10.5074L7.04582 8.5843Z" fill="currentColor"/></svg>"#;
+const ICON_BITCOIN: &str = r#"<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.28295 7.96658L8.23361 7.95179L8.76146 5.8347C8.81784 5.84928 8.88987 5.86543 8.97324 5.88412C9.67913 6.04237 11.1984 6.38297 10.9233 7.49805C10.6279 8.67114 8.87435 8.14427 8.28295 7.96658Z" fill="currentColor"/><path d="M7.3698 11.4046L7.4555 11.43C8.18407 11.6467 10.2516 12.2615 10.532 11.0972C10.8209 9.97593 8.96224 9.53925 8.13013 9.34375C8.0389 9.32232 7.96002 9.30378 7.89765 9.28756L7.3698 11.4046Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18ZM12.8732 7.61593C13.0794 6.31428 12.1803 5.63589 10.9322 5.17799L11.3709 3.40745L10.3814 3.16221L9.95392 4.88751C9.88913 4.87105 9.82482 4.85441 9.76074 4.83784C9.56538 4.78731 9.3721 4.73731 9.17436 4.69431L9.6018 2.96901L8.58479 2.71696L8.15735 4.44226L6.13863 3.94193L5.847 5.12223C5.847 5.12223 6.59551 5.285 6.56824 5.30098C6.96889 5.40897 7.03686 5.69278 7.01489 5.90971L6.50629 7.91664L5.80746 10.7404C5.75255 10.8744 5.61847 11.0659 5.34426 10.9993C5.35573 11.012 4.61643 10.8087 4.61643 10.8087L4.12964 12.0541L6.08834 12.5875L5.65196 14.3489L6.63523 14.5926L7.07161 12.8312C7.22991 12.8767 7.38989 12.9139 7.54471 12.95C7.66051 12.9769 7.77355 13.0032 7.8807 13.0318L7.44432 14.7931L8.42939 15.0373L8.86577 13.2759C10.5611 13.5993 11.841 13.448 12.4129 11.7791C12.8726 10.4484 12.4427 9.68975 11.5496 9.18998C12.2207 9.02654 12.7174 8.56346 12.8732 7.61593Z" fill="currentColor"/></svg>"#;
fn blocktype_name(blocktype: &BlockType) -> &'static str {
match blocktype {
BlockType::MentionBech32 => "mention",
@@ -259,15 +299,16 @@ fn build_note_content_html(
let timestamp_attr = timestamp_value.to_string();
format!(
- r#"<div class="note">
- <div class="note-header">
- <img src="{pfp}" class="note-author-avatar" />
- <div class="note-author-name">{author}</div>
- <div class="note-header-separator">·</div>
- <time class="note-timestamp" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
- </div>
- <div class="note-content">{body}</div>
- </div>"#,
+ r#"<article class="damus-card damus-note">
+ <header class="damus-note-header">
+ <img src="{pfp}" class="damus-note-avatar" alt="{author} profile picture" />
+ <div>
+ <div class="damus-note-author">{author}</div>
+ <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
+ </div>
+ </header>
+ <div class="damus-note-body">{body}</div>
+ </article>"#,
pfp = pfp_attr,
author = author_display,
ts = timestamp_attr,
@@ -293,19 +334,19 @@ fn build_article_content_html(
.map(|url| {
let url_attr = html_escape::encode_double_quoted_attribute(url);
format!(
- r#"<img src="{url}" class="article-hero" alt="Article header image" />"#,
+ r#"<img src="{url}" class="damus-article-hero" alt="Article header image" />"#,
url = url_attr
)
})
.unwrap_or_default();
let summary_markup = summary_html
- .map(|summary| format!(r#"<p class="article-summary">{}</p>"#, summary))
+ .map(|summary| format!(r#"<p class="damus-article-summary">{}</p>"#, summary))
.unwrap_or_default();
let mut topics_markup = String::new();
if !topics.is_empty() {
- topics_markup.push_str(r#"<div class="article-topics">"#);
+ topics_markup.push_str(r#"<div class="damus-article-topics">"#);
for topic in topics {
if topic.is_empty() {
continue;
@@ -313,7 +354,7 @@ fn build_article_content_html(
let topic_text = html_escape::encode_text(topic);
let _ = write!(
topics_markup,
- r#"<span class="article-topic">#{}</span>"#,
+ r#"<span class="damus-article-topic">#{}</span>"#,
topic_text
);
}
@@ -321,19 +362,20 @@ fn build_article_content_html(
}
format!(
- r#"<div class="note article-note">
- <div class="note-header">
- <img src="{pfp}" class="note-author-avatar" />
- <div class="note-author-name">{author}</div>
- <div class="note-header-separator">·</div>
- <time class="note-timestamp" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
- </div>
- <h1 class="article-title">{title}</h1>
+ r#"<article class="damus-card damus-note">
+ <header class="damus-note-header">
+ <img src="{pfp}" class="damus-note-avatar" alt="{author} profile picture" />
+ <div>
+ <div class="damus-note-author">{author}</div>
+ <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
+ </div>
+ </header>
+ <h1 class="damus-article-title">{title}</h1>
{hero}
{summary}
{topics}
- <div class="article-content">{body}</div>
- </div>"#,
+ <div class="damus-note-body">{body}</div>
+ </article>"#,
pfp = pfp_attr,
author = author_display,
ts = timestamp_attr,
@@ -409,11 +451,586 @@ const LOCAL_TIME_SCRIPT: &str = r#"
</script>
"#;
+pub const DAMUS_PLATFORM_SCRIPT: &str = r#"
+ <script>
+ (function() {
+ 'use strict';
+ var PLATFORM_MAP = {
+ ios: {
+ url: 'https://apps.apple.com/us/app/damus/id1628663131',
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ },
+ android: {
+ url: 'https://damus.io/android/',
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ },
+ desktop: {
+ url: 'https://damus.io/notedeck/',
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ }
+ };
+
+ var PLATFORM_LABELS = {
+ ios: 'iOS',
+ android: 'Android',
+ desktop: 'Desktop'
+ };
+
+ function detectPlatform() {
+ var ua = navigator.userAgent || '';
+ var platform = navigator.platform || '';
+ if (/android/i.test(ua)) {
+ return 'android';
+ }
+ if (/iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && 'ontouchend' in document)) {
+ return 'ios';
+ }
+ if (/Mac/.test(platform) || /Win/.test(platform) || /Linux/.test(platform)) {
+ return 'desktop';
+ }
+ return null;
+ }
+
+ var platform = detectPlatform();
+ var mapping = platform && PLATFORM_MAP[platform];
+ var anchors = document.querySelectorAll('[data-damus-cta]');
+
+ Array.prototype.forEach.call(anchors, function(anchor) {
+ var fallbackUrl = anchor.getAttribute('data-default-url') || anchor.getAttribute('href') || '';
+ var fallbackTarget = anchor.getAttribute('data-default-target') || anchor.getAttribute('target') || '';
+ var selected = mapping || { url: fallbackUrl, target: fallbackTarget };
+
+ if (selected.url) {
+ anchor.setAttribute('href', selected.url);
+ }
+
+ if (selected.target) {
+ anchor.setAttribute('target', selected.target);
+ } else {
+ anchor.removeAttribute('target');
+ }
+
+ if (mapping && mapping.rel) {
+ anchor.setAttribute('rel', mapping.rel);
+ } else if (!selected.target) {
+ anchor.removeAttribute('rel');
+ }
+
+ if (platform && mapping) {
+ anchor.setAttribute('data-damus-platform', platform);
+ var label = PLATFORM_LABELS[platform] || platform;
+ anchor.setAttribute('aria-label', 'Open in Damus (' + label + ')');
+ }
+ });
+ }());
+ </script>
+"#;
+
+pub fn serve_profile_html(
+ app: &Notecrumbs,
+ nip: &Nip19,
+ profile_rd: Option<&ProfileRenderData>,
+ r: Request<hyper::body::Incoming>,
+) -> Result<Response<Full<Bytes>>, Error> {
+ let profile_key = match profile_rd {
+ None | Some(ProfileRenderData::Missing(_)) => {
+ let mut data = Vec::new();
+ let _ = write!(data, "Profile not found :(");
+ return Ok(Response::builder()
+ .header(header::CONTENT_TYPE, "text/html")
+ .status(StatusCode::NOT_FOUND)
+ .body(Full::new(Bytes::from(data)))?);
+ }
+
+ Some(ProfileRenderData::Profile(profile_key)) => *profile_key,
+ };
+
+ let txn = Transaction::new(&app.ndb)?;
+
+ let profile_rec = match app.ndb.get_profile_by_key(&txn, profile_key) {
+ Ok(profile_rec) => profile_rec,
+ Err(_) => {
+ let mut data = Vec::new();
+ let _ = write!(data, "Profile not found :(");
+ return Ok(Response::builder()
+ .header(header::CONTENT_TYPE, "text/html")
+ .status(StatusCode::NOT_FOUND)
+ .body(Full::new(Bytes::from(data)))?);
+ }
+ };
+
+ let profile_record = profile_rec.record();
+ let profile_data = profile_record.profile();
+
+ let name_fallback = "nostrich";
+ let username_raw = profile_data
+ .and_then(|profile| profile.name())
+ .map(str::trim)
+ .filter(|name| !name.is_empty())
+ .unwrap_or(name_fallback);
+ let display_name_raw = profile_data
+ .and_then(|profile| profile.display_name())
+ .map(str::trim)
+ .filter(|display| !display.is_empty())
+ .unwrap_or(username_raw);
+ let about_raw = profile_data
+ .and_then(|profile| profile.about())
+ .map(str::trim)
+ .filter(|about| !about.is_empty())
+ .unwrap_or("");
+ let pfp_url_raw = profile_data
+ .and_then(|profile| profile.picture())
+ .map(str::trim)
+ .filter(|url| !url.is_empty())
+ .unwrap_or("https://damus.io/img/no-profile.svg");
+
+ let display_name_html = html_escape::encode_text(display_name_raw).into_owned();
+ let username_html = html_escape::encode_text(username_raw).into_owned();
+ let pfp_attr = html_escape::encode_double_quoted_attribute(pfp_url_raw).into_owned();
+
+ let mut relay_entries = Vec::new();
+ let mut profile_pubkey: Option<[u8; 32]> = None;
+ let profile_note_key = NoteKey::new(profile_record.note_key());
+ if let Ok(profile_note) = app.ndb.get_note_by_key(&txn, profile_note_key) {
+ let pubkey = *profile_note.pubkey();
+ profile_pubkey = Some(pubkey);
+ if let Ok(results) = app.ndb.query(
+ &txn,
+ &[Filter::new()
+ .authors([&pubkey])
+ .kinds([10002])
+ .limit(10)
+ .build()],
+ 10,
+ ) {
+ let mut latest_event = None;
+ let mut latest_created_at = 0u64;
+
+ for result in &results {
+ let created_at = result.note.created_at();
+ if created_at >= latest_created_at {
+ latest_created_at = created_at;
+ latest_event = Some(&result.note);
+ }
+ }
+
+ if let Some(relay_note) = latest_event {
+ for tag in relay_note.tags() {
+ let mut iter = tag.into_iter();
+ let Some(tag_kind) = iter.next().and_then(|item| item.variant().str()) else {
+ continue;
+ };
+ if tag_kind != "r" {
+ continue;
+ }
+
+ let Some(url) = iter.next().and_then(|item| item.variant().str()) else {
+ continue;
+ };
+ let marker = iter.next().and_then(|item| item.variant().str());
+ merge_relay_entry(&mut relay_entries, url, marker);
+ }
+ }
+ }
+ }
+
+ let mut meta_rows = String::new();
+ if let Some(pubkey) = profile_pubkey.as_ref() {
+ if let Ok(pk) = PublicKey::from_slice(pubkey) {
+ if let Ok(npub) = pk.to_bech32() {
+ let npub_text = html_escape::encode_text(&npub).into_owned();
+ let npub_href = format!("nostr:{npub}");
+ let npub_href_attr =
+ html_escape::encode_double_quoted_attribute(&npub_href).into_owned();
+ let _ = write!(
+ meta_rows,
+ r#"<div class="damus-profile-meta-row damus-profile-meta-row--npub"><span class="damus-meta-icon" aria-hidden="true">{icon}</span><a href="{href}">{value}</a><span class="damus-sr-only">npub</span></div>"#,
+ icon = ICON_KEY_CIRCLE,
+ href = npub_href_attr,
+ value = npub_text
+ );
+ }
+ }
+ }
+
+ if let Some(nip05) = profile_data
+ .and_then(|profile| profile.nip05())
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ {
+ let nip05_html = html_escape::encode_text(nip05).into_owned();
+ let _ = write!(
+ meta_rows,
+ r#"<div class="damus-profile-meta-row damus-profile-meta-row--nip05"><span class="damus-meta-icon" aria-hidden="true">{icon}</span><span>{value}</span><span class="damus-sr-only">nip05</span></div>"#,
+ icon = ICON_CONTACT_CIRCLE,
+ value = nip05_html
+ );
+ }
+
+ if let Some(website) = profile_data
+ .and_then(|profile| profile.website())
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ {
+ let href = if website.starts_with("http://") || website.starts_with("https://") {
+ website.to_owned()
+ } else {
+ format!("https://{website}")
+ };
+ let href_attr = html_escape::encode_double_quoted_attribute(&href).into_owned();
+ let text_html = html_escape::encode_text(website).into_owned();
+ let _ = write!(
+ meta_rows,
+ r#"<div class="damus-profile-meta-row damus-profile-meta-row--website"><span class="damus-meta-icon" aria-hidden="true">{icon}</span><a href="{href}" target="_blank" rel="noopener noreferrer">{value}</a><span class="damus-sr-only">website</span></div>"#,
+ icon = ICON_LINK_CIRCLE,
+ href = href_attr,
+ value = text_html
+ );
+ }
+
+ if let Some(lud16) = profile_data
+ .and_then(|profile| profile.lud16())
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ {
+ let lud16_html = html_escape::encode_text(lud16).into_owned();
+ let _ = write!(
+ meta_rows,
+ r#"<div class="damus-profile-meta-row damus-profile-meta-row--lnurl"><span class="damus-meta-icon" aria-hidden="true">{icon}</span><span>{value}</span><span class="damus-sr-only">lnurl</span></div>"#,
+ icon = ICON_BITCOIN,
+ value = lud16_html
+ );
+ }
+
+ let profile_meta_html = if meta_rows.is_empty() {
+ String::new()
+ } else {
+ format!(
+ r#"<div class="damus-profile-meta">{rows}</div>"#,
+ rows = meta_rows
+ )
+ };
+
+ let mut recent_notes_html = String::new();
+ if let Some(pubkey) = profile_pubkey.as_ref() {
+ let notes_filter = Filter::new()
+ .authors([pubkey])
+ .kinds([1])
+ .limit(PROFILE_FEED_RECENT_LIMIT as u64)
+ .build();
+
+ match app
+ .ndb
+ .query(&txn, &[notes_filter], PROFILE_FEED_RECENT_LIMIT as i32)
+ {
+ Ok(mut note_results) => {
+ if note_results.is_empty() {
+ recent_notes_html.push_str(
+ r#"<section class="damus-section"><h2 class="damus-section-title">Recent Notes</h2><div class="damus-card"><p class="damus-supporting muted">No recent notes yet.</p></div></section>"#,
+ );
+ } else {
+ note_results.sort_by_key(|result| result.note.created_at());
+ note_results.reverse();
+ recent_notes_html
+ .push_str(r#"<section class="damus-section"><h2 class="damus-section-title">Recent Notes</h2>"#);
+ for result in note_results.into_iter().take(PROFILE_FEED_RECENT_LIMIT) {
+ let timestamp_attr = result.note.created_at().to_string();
+ let note_body =
+ if let Ok(blocks) = app.ndb.get_blocks_by_key(&txn, result.note_key) {
+ let mut buf = Vec::new();
+ render_note_content(&mut buf, &result.note, &blocks);
+ String::from_utf8(buf).unwrap_or_default()
+ } else {
+ html_escape::encode_text(result.note.content()).into_owned()
+ };
+
+ let _ = write!(
+ recent_notes_html,
+ r#"<article class="damus-card damus-note">
+ <header class="damus-note-header">
+ <img src="{pfp}" class="damus-note-avatar" alt="{display} profile picture" />
+ <div>
+ <div class="damus-note-author">{display}</div>
+ <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
+ </div>
+ </header>
+ <div class="damus-note-body">{body}</div>
+ </article>"#,
+ pfp = pfp_attr.as_str(),
+ display = display_name_html.as_str(),
+ ts = timestamp_attr,
+ body = note_body
+ );
+ }
+ recent_notes_html.push_str("</section>");
+ }
+ }
+ Err(err) => {
+ warn!("failed to query recent notes: {err}");
+ }
+ }
+ }
+
+ let relay_section_html = if relay_entries.is_empty() {
+ String::from(r#"<div class="damus-relays muted">No relay list published yet.</div>"#)
+ } else {
+ let relay_count = relay_entries.len();
+ let relay_count_label = format!("Relays ({relay_count})");
+ let relay_count_html = html_escape::encode_text(&relay_count_label).into_owned();
+
+ let mut list_markup = String::new();
+ for entry in &relay_entries {
+ let url_text = html_escape::encode_text(&entry.url).into_owned();
+ let role_text = match (entry.read, entry.write) {
+ (true, true) => "read & write",
+ (true, false) => "read",
+ (false, true) => "write",
+ _ => "unspecified",
+ };
+ let role_html = html_escape::encode_text(role_text).into_owned();
+ let _ = write!(
+ list_markup,
+ r#"<li>{url}<span class="damus-relay-role"> – {role}</span></li>"#,
+ url = url_text,
+ role = role_html
+ );
+ }
+
+ format!(
+ r#"<details class="damus-relays">
+ <summary>{count}</summary>
+ <ul class="damus-relay-list">
+ {items}
+ </ul>
+ </details>"#,
+ count = relay_count_html,
+ items = list_markup
+ )
+ };
+
+ let host = r
+ .headers()
+ .get(header::HOST)
+ .and_then(|value| value.to_str().ok())
+ .unwrap_or("localhost:3000");
+ let base_url = format!("http://{host}");
+ let bech32 = nip.to_bech32().unwrap_or_default();
+ let canonical_url = format!("{base_url}/{bech32}");
+
+ let fallback_image_url = format!("{base_url}/{bech32}.png");
+ let og_image = if pfp_url_raw.is_empty() {
+ fallback_image_url.clone()
+ } else {
+ pfp_url_raw.to_string()
+ };
+
+ let mut og_description_raw = if about_raw.is_empty() {
+ format!("{} on nostr", display_name_raw)
+ } else {
+ about_raw.to_string()
+ };
+
+ if og_description_raw.is_empty() {
+ og_description_raw = display_name_raw.to_string();
+ }
+
+ let og_image_url_raw = if og_image.trim().is_empty() {
+ fallback_image_url
+ } else {
+ og_image.clone()
+ };
+
+ let page_title_text = format!("{} on nostr", display_name_raw);
+ let og_image_alt_text = format!("{}: {}", display_name_raw, og_description_raw);
+
+ let page_title_html = html_escape::encode_text(&page_title_text).into_owned();
+ let og_description_attr =
+ html_escape::encode_double_quoted_attribute(&og_description_raw).into_owned();
+ let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url_raw).into_owned();
+ let og_title_attr = html_escape::encode_double_quoted_attribute(&page_title_text).into_owned();
+ let og_image_alt_attr =
+ html_escape::encode_double_quoted_attribute(&og_image_alt_text).into_owned();
+ let canonical_url_attr =
+ html_escape::encode_double_quoted_attribute(&canonical_url).into_owned();
+
+ let about_html = if about_raw.is_empty() {
+ String::new()
+ } else {
+ let about_text = html_escape::encode_text(about_raw)
+ .into_owned()
+ .replace("\n", "<br/>");
+ format!(r#"<p class="damus-profile-about">{}</p>"#, about_text)
+ };
+
+ let main_content_html = format!(
+ r#"<article class="damus-card damus-profile-card">
+ <header class="damus-profile-header">
+ <img src="{pfp}" alt="{display} profile picture" class="damus-note-avatar" />
+ <div class="damus-profile-names">
+ <div class="damus-note-author">{display}</div>
+ <div class="damus-profile-handle">@{username}</div>
+ </div>
+ </header>
+ {about}
+ {meta}
+ {relays}
+ </article>
+ {recent_notes}"#,
+ pfp = pfp_attr.as_str(),
+ display = display_name_html.as_str(),
+ username = username_html,
+ about = about_html,
+ meta = profile_meta_html,
+ relays = relay_section_html,
+ recent_notes = recent_notes_html,
+ );
+
+ let mut data = Vec::new();
+ let scripts = format!("{LOCAL_TIME_SCRIPT}{DAMUS_PLATFORM_SCRIPT}");
+
+ let page = format!(
+ "<!DOCTYPE html>\n\
+<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <title>{page_title}</title>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"description\" content=\"{og_description}\" />\n <link rel=\"preload\" href=\"/fonts/PoetsenOne-Regular.ttf\" as=\"font\" type=\"font/ttf\" crossorigin />\n <link rel=\"stylesheet\" href=\"/damus.css\" type=\"text/css\" />\n <meta property=\"og:title\" content=\"{og_title}\" />\n <meta property=\"og:description\" content=\"{og_description}\" />\n <meta property=\"og:type\" content=\"profile\" />\n <meta property=\"og:url\" content=\"{canonical_url}\" />\n <meta property=\"og:image\" content=\"{og_image}\" />\n <meta property=\"og:image:alt\" content=\"{og_image_alt}\" />\n <meta property=\"og:image:height\" content=\"600\" />\n <meta property=\"og:image:width\" content=\"1200\" />\n <meta property=\"og:image:type\" content=\"image/png\" />\n <meta property=\"og:site_name\" content=\"Damus\" />\n <meta name=\"twitter:card\" content=\"summary_large_image\" />\n <meta name=\"twitter:title\" content=\"{og_title}\" />\n <meta name=\"twitter:description\" content=\"{og_description}\" />\n <meta name=\"twitter:image\" content=\"{og_image}\" />\n <meta name=\"theme-color\" content=\"#bd66ff\" />\n </head>\n <body>\n <div class=\"damus-app\">\n <header class=\"damus-header\">\n <a class=\"damus-logo-link\" href=\"https://damus.io\" target=\"_blank\" rel=\"noopener noreferrer\"><img class=\"damus-logo-image\" src=\"/assets/logo_icon.png\" alt=\"Damus\" width=\"40\" height=\"40\" /></a>\n <div class=\"damus-header-actions\">\n <a class=\"damus-cta\" data-damus-cta data-default-url=\"nostr:{bech32}\" href=\"nostr:{bech32}\">Open in Damus</a>\n </div>\n </header>\n <main class=\"damus-main\">\n{main_content}\n </main>\n <footer class=\"damus-footer\">\n <a href=\"https://github.com/damus-io/notecrumbs\" target=\"_blank\" rel=\"noopener noreferrer\">Rendered by notecrumbs</a>\n </footer>\n </div>\n{scripts}\n </body>\n</html>\n",
+ page_title = page_title_html,
+ og_description = og_description_attr,
+ og_image = og_image_attr,
+ og_image_alt = og_image_alt_attr,
+ og_title = og_title_attr,
+ canonical_url = canonical_url_attr,
+ main_content = main_content_html,
+ bech32 = bech32,
+ scripts = scripts,
+ );
+
+ let _ = data.write(page.as_bytes());
+
+ Ok(Response::builder()
+ .header(header::CONTENT_TYPE, "text/html")
+ .status(StatusCode::OK)
+ .body(Full::new(Bytes::from(data)))?)
+}
+
+pub fn serve_homepage(r: Request<hyper::body::Incoming>) -> Result<Response<Full<Bytes>>, Error> {
+ let host = r
+ .headers()
+ .get(header::HOST)
+ .and_then(|value| value.to_str().ok())
+ .unwrap_or("localhost:3000");
+ let base_url = format!("http://{}", host);
+
+ let page_title = "Damus — notecrumbs frontend";
+ let description =
+ "Explore Nostr profiles and notes with the Damus-inspired notecrumbs frontend.";
+ let og_image_url = format!("{}/assets/default_pfp.jpg", base_url);
+
+ let canonical_url_attr = html_escape::encode_double_quoted_attribute(&base_url).into_owned();
+ let description_attr = html_escape::encode_double_quoted_attribute(description).into_owned();
+ let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url).into_owned();
+ let og_title_attr = html_escape::encode_double_quoted_attribute(page_title).into_owned();
+ let page_title_html = html_escape::encode_text(page_title).into_owned();
+
+ let profile_example = format!("{}/npub1example", base_url);
+ let note_example = format!("{}/note1example", base_url);
+ let profile_example_html = html_escape::encode_text(&profile_example).into_owned();
+ let note_example_html = html_escape::encode_text(¬e_example).into_owned();
+ let png_example_html = html_escape::encode_text(&format!("{}.png", note_example)).into_owned();
+ let json_example_html =
+ html_escape::encode_text(&format!("{}.json", profile_example)).into_owned();
+
+ let mut data = Vec::new();
+ let _ = write!(
+ data,
+ r##"<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <title>{page_title}</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="description" content="{description}" />
+ <link rel="preload" href="/fonts/PoetsenOne-Regular.ttf" as="font" type="font/ttf" crossorigin />
+ <link rel="stylesheet" href="/damus.css" type="text/css" />
+ <meta property="og:title" content="{og_title}" />
+ <meta property="og:description" content="{description}" />
+ <meta property="og:type" content="website" />
+ <meta property="og:url" content="{canonical_url}" />
+ <meta property="og:image" content="{og_image}" />
+ <meta property="og:site_name" content="Damus" />
+ <meta name="twitter:card" content="summary_large_image" />
+ <meta name="twitter:title" content="{og_title}" />
+ <meta name="twitter:description" content="{description}" />
+ <meta name="twitter:image" content="{og_image}" />
+ <meta name="theme-color" content="#bd66ff" />
+ </head>
+ <body>
+ <div class="damus-app">
+ <header class="damus-header">
+ <a class="damus-logo-link" href="https://damus.io" target="_blank" rel="noopener noreferrer"><img class="damus-logo-image" src="/assets/logo_icon.png" alt="Damus" width="40" height="40" /></a>
+ <div class="damus-header-actions">
+ <a class="damus-link" href="https://damus.io" target="_blank" rel="noopener noreferrer">damus.io</a>
+ <a class="damus-cta" data-damus-cta data-default-url="https://damus.io" data-default-target="_blank" rel="noopener noreferrer" href="https://damus.io">Open in Damus</a>
+ </div>
+ </header>
+ <main class="damus-main">
+ <section class="damus-card">
+ <h1>Damus</h1>
+ <p class="damus-supporting">
+ New to Nostr? You're in the right place. This interface captures the Damus aesthetic while running locally on notecrumbs.
+ </p>
+ <p class="damus-supporting">
+ Paste any Nostr bech32 identifier after the slash—for example <code>{profile_example}</code>—to render a profile or note instantly.
+ </p>
+ </section>
+ <section class="damus-card" id="details">
+ <h2 class="damus-section-title">Quick paths</h2>
+ <ul>
+ <li><code>{profile_example}</code> — profile preview.</li>
+ <li><code>{note_example}</code> — note/article preview.</li>
+ <li><code>{png_example}</code> — PNG share card.</li>
+ <li><code>{json_example}</code> — raw profile data.</li>
+ </ul>
+ </section>
+ <section class="damus-card">
+ <p class="damus-supporting">
+ Rendering is powered by <a href="https://github.com/damus-io/notecrumbs" target="_blank" rel="noopener noreferrer">notecrumbs</a>.
+ Explore the official Damus apps and community at <a href="https://damus.io" target="_blank" rel="noopener noreferrer">damus.io</a>.
+ </p>
+ </section>
+ </main>
+ <footer class="damus-footer">
+ <span>Theme inspired by the Damus experience.</span>
+ <span>Bring your own keys & relays.</span>
+ </footer>
+ </div>
+{platform_script}
+ </body>
+</html>
+"##,
+ page_title = page_title_html,
+ description = description_attr,
+ og_title = og_title_attr,
+ canonical_url = canonical_url_attr,
+ og_image = og_image_attr,
+ profile_example = profile_example_html,
+ note_example = note_example_html,
+ png_example = png_example_html,
+ json_example = json_example_html,
+ platform_script = DAMUS_PLATFORM_SCRIPT,
+ );
+
+ Ok(Response::builder()
+ .header(header::CONTENT_TYPE, "text/html")
+ .status(StatusCode::OK)
+ .body(Full::new(Bytes::from(data)))?)
+}
+
pub fn serve_note_html(
app: &Notecrumbs,
nip19: &Nip19,
note_rd: &NoteAndProfileRenderData,
- _r: Request<hyper::body::Incoming>,
+ r: Request<hyper::body::Incoming>,
) -> Result<Response<Full<Bytes>>, Error> {
let mut data = Vec::new();
@@ -441,26 +1058,29 @@ pub fn serve_note_html(
.unwrap_or("nostrich");
let profile_name_html = html_escape::encode_text(profile_name_raw).into_owned();
- let default_pfp_url = "https://damus.io/img/no-profile.svg";
+ let default_pfp_url = "/assets/default_pfp.jpg";
let pfp_url_raw = profile_data
.and_then(|profile| profile.picture())
.unwrap_or(default_pfp_url);
- let hostname = "https://damus.io";
+ let host = r
+ .headers()
+ .get(header::HOST)
+ .and_then(|value| value.to_str().ok())
+ .unwrap_or("localhost:3000");
+ let base_url = format!("http://{}", host);
let bech32 = nip19.to_bech32().unwrap();
- let canonical_url = format!("{}/{}", hostname, bech32);
- let fallback_image_url = format!("{}/{}.png", hostname, bech32);
+ let canonical_url = format!("{}/{}", base_url, bech32);
+ let fallback_image_url = format!("{}/{}.png", base_url, bech32);
let mut display_title_raw = profile_name_raw.to_string();
let mut og_description_raw = collapse_whitespace(abbreviate(note.content(), 64));
let mut og_image_url_raw = fallback_image_url.clone();
let mut timestamp_value = note.created_at();
- let mut page_heading = "Note";
let mut og_type = "website";
let author_display_html = profile_name_html.clone();
let main_content_html = if matches!(note.kind(), 30023 | 30024) {
- page_heading = "Article";
og_type = "article";
let ArticleMetadata {
@@ -539,7 +1159,6 @@ pub fn serve_note_html(
let og_image_alt_text = format!("{}: {}", display_title_raw, og_description_raw);
let page_title_html = html_escape::encode_text(&page_title_text).into_owned();
- let page_heading_html = html_escape::encode_text(page_heading).into_owned();
let og_description_attr =
html_escape::encode_double_quoted_attribute(&og_description_raw).into_owned();
let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url_raw).into_owned();
@@ -548,63 +1167,54 @@ pub fn serve_note_html(
html_escape::encode_double_quoted_attribute(&og_image_alt_text).into_owned();
let canonical_url_attr =
html_escape::encode_double_quoted_attribute(&canonical_url).into_owned();
+ let scripts = format!("{LOCAL_TIME_SCRIPT}{DAMUS_PLATFORM_SCRIPT}");
let _ = write!(
data,
- r#"
- <html>
- <head>
- <title>{page_title}</title>
- <link rel="stylesheet" href="https://damus.io/css/notecrumbs.css" type="text/css" />
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <meta name="apple-itunes-app" content="app-id=1628663131, app-argument=damus:nostr:{bech32}"/>
- <meta charset="UTF-8">
- <meta property="og:description" content="{og_description}" />
- <meta property="og:image" content="{og_image}"/>
- <meta property="og:image:alt" content="{og_image_alt}" />
- <meta property="og:image:height" content="600" />
- <meta property="og:image:width" content="1200" />
- <meta property="og:image:type" content="image/png" />
- <meta property="og:site_name" content="Damus" />
- <meta property="og:title" content="{og_title}" />
- <meta property="og:url" content="{canonical_url}"/>
- <meta property="og:type" content="{og_type}"/>
- <meta name="og:type" content="{og_type}"/>
- <meta name="twitter:image:src" content="{og_image}" />
- <meta name="twitter:site" content="@damusapp" />
- <meta name="twitter:card" content="summary_large_image" />
- <meta name="twitter:title" content="{og_title}" />
- <meta name="twitter:description" content="{og_description}" />
- </head>
- <body>
- <main>
- <div class="container">
- <div class="top-menu">
- <a href="https://damus.io" target="_blank">
- <img src="https://damus.io/logo_icon.png" class="logo" />
- </a>
- </div>
- <h3 class="page-heading">{page_heading}</h3>
- <div class="note-container">
- {main_content}
- </div>
- </div>
- <div class="note-actions-footer">
- <a href="nostr:{bech32}" class="muted-link">Open with default Nostr client</a>
- </div>
- </main>
- <footer>
- <span class="footer-note">
- <a href="https://damus.io">Damus</a> is a decentralized social network app built on the Nostr protocol.
- </span>
- <span class="copyright-note">
- © Damus Nostr Inc.
- </span>
- </footer>
- {script}
- </body>
- </html>
- "#,
+ r##"<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <title>{page_title}</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="description" content="{og_description}" />
+ <link rel="preload" href="/fonts/PoetsenOne-Regular.ttf" as="font" type="font/ttf" crossorigin />
+ <link rel="stylesheet" href="/damus.css" type="text/css" />
+ <meta property="og:title" content="{og_title}" />
+ <meta property="og:description" content="{og_description}" />
+ <meta property="og:type" content="{og_type}" />
+ <meta property="og:url" content="{canonical_url}" />
+ <meta property="og:image" content="{og_image}" />
+ <meta property="og:image:alt" content="{og_image_alt}" />
+ <meta property="og:image:height" content="600" />
+ <meta property="og:image:width" content="1200" />
+ <meta property="og:image:type" content="image/png" />
+ <meta property="og:site_name" content="Damus" />
+ <meta name="twitter:card" content="summary_large_image" />
+ <meta name="twitter:title" content="{og_title}" />
+ <meta name="twitter:description" content="{og_description}" />
+ <meta name="twitter:image" content="{og_image}" />
+ <meta name="theme-color" content="#bd66ff" />
+ </head>
+ <body>
+ <div class="damus-app">
+ <header class="damus-header">
+ <a class="damus-logo-link" href="https://damus.io" target="_blank" rel="noopener noreferrer"><img class="damus-logo-image" src="/assets/logo_icon.png" alt="Damus" width="40" height="40" /></a>
+ <div class="damus-header-actions">
+ <a class="damus-cta" data-damus-cta data-default-url="nostr:{bech32}" href="nostr:{bech32}">Open in Damus</a>
+ </div>
+ </header>
+ <main class="damus-main">
+ {main_content}
+ </main>
+ <footer class="damus-footer">
+ <a href="https://github.com/damus-io/notecrumbs" target="_blank" rel="noopener noreferrer">Rendered by notecrumbs</a>
+ </footer>
+ </div>
+{scripts}
+ </body>
+</html>
+"##,
page_title = page_title_html,
og_description = og_description_attr,
og_image = og_image_attr,
@@ -612,10 +1222,9 @@ pub fn serve_note_html(
og_title = og_title_attr,
canonical_url = canonical_url_attr,
og_type = og_type,
- page_heading = page_heading_html,
main_content = main_content_html,
bech32 = bech32,
- script = LOCAL_TIME_SCRIPT,
+ scripts = scripts,
);
Ok(Response::builder()
diff --git a/src/main.rs b/src/main.rs
@@ -7,7 +7,7 @@ use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
-use std::io::Write;
+use metrics_exporter_prometheus::PrometheusHandle;
use std::sync::Arc;
use tokio::net::TcpListener;
use tracing::{error, info};
@@ -17,11 +17,9 @@ use crate::{
render::{ProfileRenderData, RenderData},
};
use nostr_sdk::prelude::*;
-use nostrdb::{Config, Ndb, Transaction};
+use nostrdb::{Config, Ndb, NoteKey, Transaction};
use std::time::Duration;
-use lru::LruCache;
-
mod abbrev;
mod error;
mod fonts;
@@ -29,21 +27,25 @@ mod gradient;
mod html;
mod nip19;
mod pfp;
+mod relay_pool;
mod render;
-mod timeout;
-use crate::secp256k1::XOnlyPublicKey;
+use relay_pool::RelayPool;
-type ImageCache = LruCache<XOnlyPublicKey, egui::TextureHandle>;
+const FRONTEND_CSS: &str = include_str!("../assets/damus.css");
+const POETSEN_FONT: &[u8] = include_bytes!("../fonts/PoetsenOne-Regular.ttf");
+const DEFAULT_PFP_IMAGE: &[u8] = include_bytes!("../assets/default_pfp.jpg");
+const DAMUS_LOGO_ICON: &[u8] = include_bytes!("../assets/logo_icon.png");
#[derive(Clone)]
pub struct Notecrumbs {
pub ndb: Ndb,
- keys: Keys,
+ _keys: Keys,
+ relay_pool: Arc<RelayPool>,
font_data: egui::FontData,
- _img_cache: Arc<ImageCache>,
default_pfp: egui::ImageData,
background: egui::ImageData,
+ prometheus_handle: PrometheusHandle,
/// How long do we wait for remote note requests
_timeout: Duration,
@@ -70,58 +72,52 @@ fn is_utf8_char_boundary(c: u8) -> bool {
(c as i8) >= -0x40
}
-fn serve_profile_html(
+async fn serve(
app: &Notecrumbs,
- _nip: &Nip19,
- profile_rd: Option<&ProfileRenderData>,
- _r: Request<hyper::body::Incoming>,
+ r: Request<hyper::body::Incoming>,
) -> Result<Response<Full<Bytes>>, Error> {
- let mut data = Vec::new();
+ if r.uri().path() == "/metrics" {
+ let body = app.prometheus_handle.render();
+ return Ok(Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "text/plain; version=0.0.4")
+ .body(Full::new(Bytes::from(body)))?);
+ }
- let profile_key = match profile_rd {
- None | Some(ProfileRenderData::Missing(_)) => {
- let _ = write!(data, "Profile not found :(");
+ match r.uri().path() {
+ "/damus.css" => {
return Ok(Response::builder()
- .header(header::CONTENT_TYPE, "text/html")
- .status(StatusCode::NOT_FOUND)
- .body(Full::new(Bytes::from(data)))?);
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "text/css; charset=utf-8")
+ .body(Full::new(Bytes::from_static(FRONTEND_CSS.as_bytes())))?);
}
+ "/fonts/PoetsenOne-Regular.ttf" => {
+ return Ok(Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "font/ttf")
+ .header(header::CACHE_CONTROL, "public, max-age=604800, immutable")
+ .body(Full::new(Bytes::from_static(POETSEN_FONT)))?);
+ }
+ "/assets/default_pfp.jpg" => {
+ return Ok(Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "image/jpeg")
+ .header(header::CACHE_CONTROL, "public, max-age=604800")
+ .body(Full::new(Bytes::from_static(DEFAULT_PFP_IMAGE)))?);
+ }
+ "/assets/logo_icon.png" => {
+ return Ok(Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "image/png")
+ .header(header::CACHE_CONTROL, "public, max-age=604800, immutable")
+ .body(Full::new(Bytes::from_static(DAMUS_LOGO_ICON)))?);
+ }
+ "/" => {
+ return html::serve_homepage(r);
+ }
+ _ => {}
+ }
- Some(ProfileRenderData::Profile(profile_key)) => *profile_key,
- };
-
- let txn = Transaction::new(&app.ndb)?;
-
- let profile_rec = if let Ok(profile_rec) = app.ndb.get_profile_by_key(&txn, profile_key) {
- profile_rec
- } else {
- let _ = write!(data, "Profile not found :(");
- return Ok(Response::builder()
- .header(header::CONTENT_TYPE, "text/html")
- .status(StatusCode::NOT_FOUND)
- .body(Full::new(Bytes::from(data)))?);
- };
-
- let _ = write!(
- data,
- "{}",
- profile_rec
- .record()
- .profile()
- .and_then(|p| p.name())
- .unwrap_or("nostrich")
- );
-
- Ok(Response::builder()
- .header(header::CONTENT_TYPE, "text/html")
- .status(StatusCode::OK)
- .body(Full::new(Bytes::from(data)))?)
-}
-
-async fn serve(
- app: &Notecrumbs,
- r: Request<hyper::body::Incoming>,
-) -> Result<Response<Full<Bytes>>, Error> {
let is_png = r.uri().path().ends_with(".png");
let is_json = r.uri().path().ends_with(".json");
let until = if is_png {
@@ -160,13 +156,43 @@ async fn serve(
// fetch extra data if we are missing it
if !render_data.is_complete() {
if let Err(err) = render_data
- .complete(app.ndb.clone(), app.keys.clone(), nip19.clone())
+ .complete(app.ndb.clone(), app.relay_pool.clone(), nip19.clone())
.await
{
error!("Error fetching completion data: {err}");
}
}
+ if let RenderData::Profile(profile_opt) = &render_data {
+ let maybe_pubkey = {
+ let txn = Transaction::new(&app.ndb)?;
+ match profile_opt {
+ Some(ProfileRenderData::Profile(profile_key)) => {
+ if let Ok(profile_rec) = app.ndb.get_profile_by_key(&txn, *profile_key) {
+ let note_key = NoteKey::new(profile_rec.record().note_key());
+ if let Ok(profile_note) = app.ndb.get_note_by_key(&txn, note_key) {
+ Some(*profile_note.pubkey())
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ }
+ Some(ProfileRenderData::Missing(pk)) => Some(*pk),
+ None => None,
+ }
+ };
+
+ if let Some(pubkey) = maybe_pubkey {
+ if let Err(err) =
+ render::fetch_profile_feed(app.relay_pool.clone(), app.ndb.clone(), pubkey).await
+ {
+ error!("Error fetching profile feed: {err}");
+ }
+ }
+ }
+
if is_png {
let data = render::render_note(app, &render_data);
@@ -187,12 +213,18 @@ async fn serve(
match render_data {
RenderData::Note(note_rd) => html::serve_note_html(app, &nip19, ¬e_rd, r),
RenderData::Profile(profile_rd) => {
- serve_profile_html(app, &nip19, profile_rd.as_ref(), r)
+ html::serve_profile_html(app, &nip19, profile_rd.as_ref(), r)
}
}
}
}
+fn get_env_timeout() -> Duration {
+ let timeout_env = std::env::var("TIMEOUT_MS").unwrap_or("2000".to_string());
+ let timeout_ms: u64 = timeout_env.parse().unwrap_or(2000);
+ Duration::from_millis(timeout_ms)
+}
+
fn get_gradient() -> egui::ColorImage {
use egui::{Color32, ColorImage};
//use egui::pos2;
@@ -226,8 +258,8 @@ fn get_gradient() -> egui::ColorImage {
}
fn get_default_pfp() -> egui::ColorImage {
- let img = std::fs::read("assets/default_pfp.jpg").expect("default pfp missing");
- let mut dyn_image = ::image::load_from_memory(&img).expect("failed to load default pfp");
+ let mut dyn_image =
+ ::image::load_from_memory(DEFAULT_PFP_IMAGE).expect("failed to load embedded default pfp");
pfp::process_pfp_bitmap(&mut dyn_image)
}
@@ -246,20 +278,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cfg = Config::new();
let ndb = Ndb::new(".", &cfg).expect("ndb failed to open");
let keys = Keys::generate();
- let timeout = timeout::get_env_timeout();
- let img_cache = Arc::new(LruCache::new(std::num::NonZeroUsize::new(64).unwrap()));
+ let timeout = get_env_timeout();
+ let prometheus_handle = metrics_exporter_prometheus::PrometheusBuilder::new()
+ .install_recorder()
+ .expect("install prometheus recorder");
+ let relay_pool = Arc::new(
+ RelayPool::new(
+ keys.clone(),
+ &["wss://relay.damus.io", "wss://nostr.wine", "wss://nos.lol"],
+ timeout,
+ )
+ .await?,
+ );
+ spawn_relay_pool_metrics_logger(relay_pool.clone());
let default_pfp = egui::ImageData::Color(Arc::new(get_default_pfp()));
let background = egui::ImageData::Color(Arc::new(get_gradient()));
let font_data = egui::FontData::from_static(include_bytes!("../fonts/NotoSans-Regular.ttf"));
let app = Notecrumbs {
ndb,
- keys,
+ _keys: keys,
+ relay_pool,
_timeout: timeout,
- _img_cache: img_cache,
background,
font_data,
default_pfp,
+ prometheus_handle,
};
// We start a loop to continuously accept incoming connections
@@ -285,3 +329,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
});
}
}
+
+fn spawn_relay_pool_metrics_logger(pool: Arc<RelayPool>) {
+ tokio::spawn(async move {
+ let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
+ loop {
+ ticker.tick().await;
+ let (stats, tracked) = pool.relay_stats().await;
+ metrics::gauge!("relay_pool_known_relays", tracked as f64);
+ info!(
+ total_relays = tracked,
+ ensure_calls = stats.ensure_calls,
+ relays_added = stats.relays_added,
+ connect_successes = stats.connect_successes,
+ connect_failures = stats.connect_failures,
+ "relay pool metrics snapshot"
+ );
+ }
+ });
+}
diff --git a/src/relay_pool.rs b/src/relay_pool.rs
@@ -0,0 +1,175 @@
+use crate::Error;
+use nostr::prelude::RelayUrl;
+use nostr_sdk::prelude::{Client, Event, Filter, Keys, ReceiverStream};
+use std::collections::HashSet;
+use std::sync::Arc;
+use tokio::sync::Mutex;
+use tokio::time::Duration;
+use tracing::{debug, info, warn};
+
+#[derive(Clone, Copy, Debug, Default)]
+pub struct RelayStats {
+ pub ensure_calls: u64,
+ pub relays_added: u64,
+ pub connect_successes: u64,
+ pub connect_failures: u64,
+}
+
+/// Persistent relay pool responsible for maintaining long-lived connections.
+#[derive(Clone)]
+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> {
+ let client = Client::builder().signer(keys).build();
+ let parsed_defaults: Vec<RelayUrl> = default_relays
+ .iter()
+ .filter_map(|url| match RelayUrl::parse(url) {
+ Ok(relay) => Some(relay),
+ Err(err) => {
+ warn!("failed to parse default relay {url}: {err}");
+ None
+ }
+ })
+ .collect();
+
+ let default_relays = Arc::<[RelayUrl]>::from(parsed_defaults);
+ let pool = Self {
+ client,
+ known_relays: Arc::new(Mutex::new(HashSet::new())),
+ default_relays: default_relays.clone(),
+ connect_timeout,
+ stats: Arc::new(Mutex::new(RelayStats::default())),
+ };
+
+ pool.ensure_relays(pool.default_relays().iter().cloned())
+ .await?;
+
+ Ok(pool)
+ }
+
+ pub fn default_relays(&self) -> &[RelayUrl] {
+ self.default_relays.as_ref()
+ }
+
+ pub async fn ensure_relays<I>(&self, relays: I) -> Result<(), Error>
+ where
+ I: IntoIterator<Item = RelayUrl>,
+ {
+ metrics::counter!("relay_pool_ensure_calls_total", 1);
+ let mut new_relays = Vec::new();
+ let mut had_new = false;
+ let mut relays_added = 0u64;
+ {
+ let mut guard = self.known_relays.lock().await;
+ for relay in relays {
+ let key = relay.to_string();
+ if guard.insert(key) {
+ new_relays.push(relay);
+ had_new = true;
+ relays_added += 1;
+ }
+ }
+ }
+
+ if relays_added > 0 {
+ metrics::counter!("relay_pool_relays_added_total", relays_added);
+ }
+
+ let mut connect_success = 0u64;
+ let mut connect_failure = 0u64;
+ for relay in new_relays {
+ debug!("adding relay {}", relay);
+ self.client
+ .add_relay(relay.clone())
+ .await
+ .map_err(|err| Error::Generic(format!("failed to add relay {relay}: {err}")))?;
+ if let Err(err) = self.client.connect_relay(relay.clone()).await {
+ warn!("failed to connect relay {}: {}", relay, err);
+ connect_failure += 1;
+ } else {
+ connect_success += 1;
+ }
+ }
+
+ if connect_success > 0 {
+ metrics::counter!("relay_pool_connect_success_total", connect_success);
+ }
+ if connect_failure > 0 {
+ metrics::counter!("relay_pool_connect_failure_total", connect_failure);
+ }
+
+ if had_new {
+ self.client.connect_with_timeout(self.connect_timeout).await;
+
+ let mut stats = self.stats.lock().await;
+ stats.ensure_calls += 1;
+ stats.relays_added += relays_added;
+ stats.connect_successes += connect_success;
+ stats.connect_failures += connect_failure;
+ let snapshot = *stats;
+ drop(stats);
+
+ let tracked = {
+ let guard = self.known_relays.lock().await;
+ guard.len()
+ };
+
+ info!(
+ total_relays = tracked,
+ ensure_calls = snapshot.ensure_calls,
+ relays_added = relays_added,
+ connect_successes = connect_success,
+ connect_failures = connect_failure,
+ "relay pool health update"
+ );
+ } else {
+ let mut stats = self.stats.lock().await;
+ stats.ensure_calls += 1;
+ }
+
+ let tracked = {
+ let guard = self.known_relays.lock().await;
+ guard.len()
+ };
+ metrics::gauge!("relay_pool_known_relays", tracked as f64);
+
+ Ok(())
+ }
+
+ pub async fn stream_events(
+ &self,
+ filters: Vec<Filter>,
+ relays: &[RelayUrl],
+ timeout: Duration,
+ ) -> Result<ReceiverStream<Event>, Error> {
+ if relays.is_empty() {
+ Ok(self.client.stream_events(filters, Some(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))
+ .await?)
+ }
+ }
+
+ pub async fn relay_stats(&self) -> (RelayStats, usize) {
+ let stats = { *self.stats.lock().await };
+ let tracked = {
+ let guard = self.known_relays.lock().await;
+ guard.len()
+ };
+ (stats, tracked)
+ }
+}
diff --git a/src/render.rs b/src/render.rs
@@ -1,5 +1,6 @@
-use crate::timeout;
-use crate::{abbrev::abbrev_str, error::Result, fonts, nip19, Error, Notecrumbs};
+use crate::{
+ abbrev::abbrev_str, error::Result, fonts, nip19, relay_pool::RelayPool, Error, Notecrumbs,
+};
use egui::epaint::Shadow;
use egui::{
pos2,
@@ -7,20 +8,29 @@ use egui::{
Color32, FontFamily, FontId, Mesh, Rect, RichText, Rounding, Shape, TextureHandle, Vec2,
Visuals,
};
-use nostr::event::kind::Kind;
-use nostr::types::{SingleLetterTag, Timestamp};
+use nostr::event::{
+ kind::Kind,
+ tag::{TagKind, TagStandard},
+};
+use nostr::nips::nip01::Coordinate;
+use nostr::types::{RelayUrl, SingleLetterTag, Timestamp};
use nostr_sdk::async_utility::futures_util::StreamExt;
use nostr_sdk::nips::nip19::Nip19;
-use nostr_sdk::prelude::{Client, EventId, Keys, PublicKey};
+use nostr_sdk::prelude::{Event, EventId, PublicKey};
+use nostr_sdk::JsonUtil;
use nostrdb::{
Block, BlockType, Blocks, FilterElement, FilterField, Mention, Ndb, Note, NoteKey, ProfileKey,
ProfileRecord, Transaction,
};
-use std::collections::{BTreeMap, BTreeSet};
+use std::collections::{BTreeMap, BTreeSet, HashSet};
+use std::sync::Arc;
+use std::time::SystemTime;
use tokio::time::{timeout, Duration};
use tracing::{debug, error, warn};
const PURPLE: Color32 = Color32::from_rgb(0xcc, 0x43, 0xc5);
+pub const PROFILE_FEED_RECENT_LIMIT: usize = 12;
+pub const PROFILE_FEED_LOOKBACK_DAYS: u64 = 30;
pub enum NoteRenderData {
Missing([u8; 32]),
@@ -189,7 +199,7 @@ fn renderdata_to_filter(render_data: &RenderData) -> Vec<nostrdb::Filter> {
filters
}
-fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter {
+pub(crate) fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter {
let mut filter = nostr::types::Filter::new();
for element in ndb_filter {
@@ -258,8 +268,12 @@ fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter {
}
fn coordinate_tag(author: &[u8; 32], kind: u64, identifier: &str) -> String {
- let pk_hex = hex::encode(author);
- format!("{}:{}:{}", kind, pk_hex, identifier)
+ let Ok(public_key) = PublicKey::from_slice(author) else {
+ return String::new();
+ };
+ let nostr_kind = Kind::from_u16(kind as u16);
+ let coordinate = Coordinate::new(nostr_kind, public_key).identifier(identifier);
+ coordinate.to_string()
}
fn build_address_filter(author: &[u8; 32], kind: u64, identifier: &str) -> nostrdb::Filter {
@@ -290,44 +304,45 @@ fn query_note_by_address<'a>(
results = ndb.query(txn, &[coord_filter], 1)?;
}
if let Some(result) = results.first() {
- Ok(result.note.clone())
+ ndb.get_note_by_key(txn, result.note_key)
} else {
Err(nostrdb::Error::NotFound)
}
}
pub async fn find_note(
+ relay_pool: Arc<RelayPool>,
ndb: Ndb,
- keys: Keys,
filters: Vec<nostr::Filter>,
nip19: &Nip19,
) -> Result<()> {
use nostr_sdk::JsonUtil;
- let client = Client::builder().signer(keys).build();
-
- let _ = client.add_relay("wss://relay.damus.io").await;
- let _ = client.add_relay("wss://nostr.wine").await;
- let _ = client.add_relay("wss://nos.lol").await;
- let expected_events = filters.len();
-
- let other_relays = nip19::nip19_relays(nip19);
- for relay in other_relays {
- let _ = client.add_relay(relay).await;
+ let mut relay_targets = nip19::nip19_relays(nip19);
+ if relay_targets.is_empty() {
+ relay_targets = relay_pool.default_relays().to_vec();
}
- client
- .connect_with_timeout(timeout::get_env_timeout())
- .await;
+ relay_pool.ensure_relays(relay_targets.clone()).await?;
debug!("finding note(s) with filters: {:?}", filters);
- let mut streamed_events = client
- .stream_events(filters, Some(timeout::get_env_timeout()))
+ let expected_events = filters.len();
+
+ let mut streamed_events = relay_pool
+ .stream_events(
+ filters,
+ &relay_targets,
+ std::time::Duration::from_millis(2000),
+ )
.await?;
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}");
+ }
+
debug!("processing event {:?}", event);
if let Err(err) = ndb.process_event(&event.as_json()) {
error!("error processing event: {err}");
@@ -343,6 +358,52 @@ pub async fn find_note(
Ok(())
}
+pub async fn fetch_profile_feed(
+ relay_pool: Arc<RelayPool>,
+ ndb: Ndb,
+ pubkey: [u8; 32],
+) -> Result<()> {
+ let relay_targets = collect_profile_relays(relay_pool.clone(), ndb.clone(), pubkey).await?;
+
+ let relay_targets_arc = Arc::new(relay_targets);
+
+ let cutoff = SystemTime::now()
+ .checked_sub(Duration::from_secs(
+ 60 * 60 * 24 * PROFILE_FEED_LOOKBACK_DAYS,
+ ))
+ .and_then(|ts| ts.duration_since(SystemTime::UNIX_EPOCH).ok())
+ .map(|dur| dur.as_secs());
+
+ let mut fetched = stream_profile_feed_once(
+ relay_pool.clone(),
+ ndb.clone(),
+ relay_targets_arc.clone(),
+ pubkey,
+ cutoff,
+ )
+ .await?;
+
+ if fetched == 0 {
+ fetched = stream_profile_feed_once(
+ relay_pool.clone(),
+ ndb.clone(),
+ relay_targets_arc.clone(),
+ pubkey,
+ None,
+ )
+ .await?;
+ }
+
+ if fetched == 0 {
+ warn!(
+ "no profile notes fetched for {} even after fallback",
+ hex::encode(pubkey)
+ );
+ }
+
+ Ok(())
+}
+
impl RenderData {
fn set_profile_key(&mut self, key: ProfileKey) {
match self {
@@ -364,8 +425,40 @@ impl RenderData {
};
}
- pub async fn complete(&mut self, ndb: Ndb, keys: Keys, nip19: Nip19) -> Result<()> {
- let mut stream = {
+ fn hydrate_from_note_key(&mut self, ndb: &Ndb, note_key: NoteKey) -> Result<bool> {
+ let txn = Transaction::new(ndb)?;
+ let note = match ndb.get_note_by_key(&txn, note_key) {
+ Ok(note) => note,
+ Err(err) => {
+ debug!(?note_key, "note key not yet visible in transaction: {err}");
+ return Ok(false);
+ }
+ };
+
+ if note.kind() == 0 {
+ match ndb.get_profilekey_by_pubkey(&txn, note.pubkey()) {
+ Ok(profile_key) => self.set_profile_key(profile_key),
+ Err(err) => {
+ debug!(
+ pubkey = %hex::encode(note.pubkey()),
+ "profile key not ready after note ingestion: {err}"
+ );
+ }
+ }
+ } else {
+ self.set_note_key(note_key);
+ }
+
+ Ok(true)
+ }
+
+ pub async fn complete(
+ &mut self,
+ ndb: Ndb,
+ relay_pool: Arc<RelayPool>,
+ nip19: Nip19,
+ ) -> Result<()> {
+ let (mut stream, fetch_handle) = {
let filter = renderdata_to_filter(self);
if filter.is_empty() {
// should really never happen unless someone broke
@@ -378,57 +471,219 @@ impl RenderData {
let filters = filter.iter().map(convert_filter).collect();
let ndb = ndb.clone();
- tokio::spawn(async move { find_note(ndb, keys, filters, &nip19).await });
- stream
+ let pool = relay_pool.clone();
+ let handle = tokio::spawn(async move { find_note(pool, ndb, filters, &nip19).await });
+ (stream, handle)
};
let wait_for = Duration::from_secs(1);
- let mut loops = 0;
+ let mut consecutive_timeouts = 0;
loop {
- if loops == 2 {
+ if !self.needs_note() && !self.needs_profile() {
break;
}
- let note_keys = if let Some(note_keys) = timeout(wait_for, stream.next()).await? {
- note_keys
- } else {
- // end of stream?
+ if consecutive_timeouts >= 5 {
+ warn!("render completion timed out waiting for remaining data");
break;
+ }
+
+ let note_keys = match timeout(wait_for, stream.next()).await {
+ Ok(Some(note_keys)) => {
+ consecutive_timeouts = 0;
+ note_keys
+ }
+ Ok(None) => {
+ // end of stream
+ break;
+ }
+ Err(_) => {
+ consecutive_timeouts += 1;
+ continue;
+ }
};
let note_keys_len = note_keys.len();
- {
- let txn = Transaction::new(&ndb)?;
-
- for note_key in note_keys {
- let note = if let Ok(note) = ndb.get_note_by_key(&txn, note_key) {
- note
- } else {
- error!("race condition in RenderData::complete?");
- continue;
- };
-
- if note.kind() == 0 {
- if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(&txn, note.pubkey()) {
- self.set_profile_key(profile_key);
- }
- } else {
- self.set_note_key(note_key);
+ for note_key in note_keys {
+ match self.hydrate_from_note_key(&ndb, note_key) {
+ Ok(true) => {}
+ Ok(false) => {
+ // keep waiting; the outer loop will retry on the next batch
+ }
+ Err(err) => {
+ error!(?note_key, "failed to hydrate note from key: {err}");
}
}
}
- if note_keys_len >= 2 {
+ if note_keys_len >= 2 && !self.needs_note() && !self.needs_profile() {
break;
}
+ }
- loops += 1;
+ match fetch_handle.await {
+ Ok(Ok(())) => Ok(()),
+ Ok(Err(err)) => Err(err),
+ Err(join_err) => Err(Error::Generic(format!(
+ "relay fetch task failed: {}",
+ join_err
+ ))),
}
+ }
+}
+
+fn collect_relay_hints(event: &Event) -> Vec<RelayUrl> {
+ let mut relays = Vec::new();
+ for tag in event.tags.iter() {
+ let candidate = match tag.kind() {
+ TagKind::Relay | TagKind::Relays => tag.content(),
+ TagKind::SingleLetter(letter) if letter.as_char() == 'r' => tag.content(),
+ _ if event.kind == Kind::ContactList => {
+ if let Some(TagStandard::PublicKey {
+ relay_url: Some(url),
+ ..
+ }) = tag.as_standardized()
+ {
+ Some(url.as_str())
+ } else {
+ tag.as_slice().get(2).map(|value| value.as_str())
+ }
+ }
+ _ => None,
+ };
- Ok(())
+ let Some(url) = candidate else {
+ continue;
+ };
+
+ if url.is_empty() {
+ continue;
+ }
+
+ match RelayUrl::parse(url) {
+ Ok(relay) => relays.push(relay),
+ Err(err) => warn!("ignoring invalid relay hint {}: {}", url, err),
+ }
}
+ relays
+}
+
+async fn ensure_relay_hints(relay_pool: &Arc<RelayPool>, event: &Event) -> Result<()> {
+ let hints = collect_relay_hints(event);
+ if hints.is_empty() {
+ return Ok(());
+ }
+ relay_pool.ensure_relays(hints).await
+}
+
+async fn collect_profile_relays(
+ relay_pool: Arc<RelayPool>,
+ ndb: Ndb,
+ pubkey: [u8; 32],
+) -> Result<Vec<RelayUrl>> {
+ relay_pool
+ .ensure_relays(relay_pool.default_relays().iter().cloned())
+ .await?;
+
+ let mut known: HashSet<String> = relay_pool
+ .default_relays()
+ .iter()
+ .map(|url| url.to_string())
+ .collect();
+ let mut targets = relay_pool.default_relays().to_vec();
+
+ let author_ref = [&pubkey];
+
+ let relay_filter = convert_filter(
+ &nostrdb::Filter::new()
+ .authors(author_ref)
+ .kinds([Kind::RelayList.as_u16() as u64])
+ .limit(1)
+ .build(),
+ );
+
+ let contact_filter = convert_filter(
+ &nostrdb::Filter::new()
+ .authors(author_ref)
+ .kinds([Kind::ContactList.as_u16() as u64])
+ .limit(1)
+ .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}");
+ }
+
+ let hints = collect_relay_hints(&event);
+ if hints.is_empty() {
+ continue;
+ }
+
+ 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?;
+ }
+ }
+
+ Ok(targets)
+}
+
+async fn stream_profile_feed_once(
+ relay_pool: Arc<RelayPool>,
+ ndb: Ndb,
+ relays: Arc<Vec<RelayUrl>>,
+ pubkey: [u8; 32],
+ since: Option<u64>,
+) -> Result<usize> {
+ let filter = {
+ let author_ref = [&pubkey];
+ let mut builder = nostrdb::Filter::new()
+ .authors(author_ref)
+ .kinds([1])
+ .limit(PROFILE_FEED_RECENT_LIMIT as u64);
+
+ if let Some(since) = since {
+ builder = builder.since(since);
+ }
+
+ convert_filter(&builder.build())
+ };
+ let mut stream = relay_pool
+ .stream_events(vec![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 {
+ warn!("failed to apply relay hints: {err}");
+ }
+ if let Err(err) = ndb.process_event(&event.as_json()) {
+ error!("error processing profile feed event: {err}");
+ } else {
+ fetched += 1;
+ }
+ }
+
+ Ok(fetched)
}
/// Attempt to locate the render data locally. Anything missing from
@@ -914,29 +1169,41 @@ mod tests {
.sign_with_keys(&keys)
.expect("sign long-form event with coordinate tag");
- let wait_filter = Filter::new().ids([event_with_d.id.as_bytes()]).build();
- let wait_filter_2 = Filter::new().ids([event_with_a_only.id.as_bytes()]).build();
+ let event_with_d_id = event_with_d.id.to_bytes();
+ let event_with_a_only_id = event_with_a_only.id.to_bytes();
+
+ let wait_filter = Filter::new()
+ .ids([&event_with_d_id, &event_with_a_only_id])
+ .limit(2)
+ .build();
+ let subscription = ndb
+ .subscribe(&[wait_filter])
+ .expect("subscribe for note ingestion");
ndb.process_event(&serde_json::to_string(&event_with_d).unwrap())
.expect("ingest event with d tag");
ndb.process_event(&serde_json::to_string(&event_with_a_only).unwrap())
.expect("ingest event with a tag");
- let sub_id = ndb.subscribe(&[wait_filter, wait_filter_2]).expect("sub");
- let _r = ndb.wait_for_notes(sub_id, 2).await;
+ let _ = ndb
+ .wait_for_notes(subscription, 2)
+ .await
+ .expect("wait for note ingestion to complete");
+
+ tokio::time::sleep(Duration::from_millis(100)).await;
{
let txn = Transaction::new(&ndb).expect("transaction for d-tag lookup");
let note = query_note_by_address(&ndb, &txn, &author, kind, identifier_with_d)
.expect("should find event by d tag");
- assert_eq!(note.id(), event_with_d.id.as_bytes());
+ assert_eq!(note.id(), &event_with_d_id);
}
{
let txn = Transaction::new(&ndb).expect("transaction for a-tag lookup");
let note = query_note_by_address(&ndb, &txn, &author, kind, identifier_with_a)
.expect("should find event via a-tag fallback");
- assert_eq!(note.id(), event_with_a_only.id.as_bytes());
+ assert_eq!(note.id(), &event_with_a_only_id);
}
drop(ndb);