From aef47dd1efdb92cab017eb7fd49fd7fa91a3945a Mon Sep 17 00:00:00 2001 From: Avi Date: Wed, 30 Sep 2026 14:18:33 -0500 Subject: [PATCH] fix(feed): query profile relays for kind-3 follow lists and kind-0 metadata The 'My contacts' feed and the identity backfill only queried the user's enabled read relays, but follow lists and metadata live on profile/outbox relays (purplepag.es aggregates them network-wide). Add PROFILE_RELAYS as an always-consulted set for profile-scoped queries (notes queries keep using only enabled relays), de-duplicated with trailing-slash normalising. Backfill now also upgrades local rows wearing the 'My Profile' create-time placeholder, not just Nip46Client rows. --- src/feed.rs | 89 +++++++++++++++++++++++++++++++++++++++---------- src/ipc.rs | 12 ++++--- src/profiles.rs | 8 +++-- 3 files changed, 83 insertions(+), 26 deletions(-) diff --git a/src/feed.rs b/src/feed.rs index f876012..737b98c 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -96,9 +96,12 @@ pub async fn profile_feed( } /// Fetch the hex public keys followed by an owner profile (kind 3 contact list). +/// +/// Profile data lives on profile relays, not necessarily on the user's read +/// relays, so the query set is the enabled relays plus PROFILE_RELAYS. async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result, AppError> { let owner = owner_pubkey(owner_hex)?; - let relay_urls = enabled_relays(settings); + let relay_urls = profile_lookup_relays(settings); if relay_urls.is_empty() { return Ok(Vec::new()); } @@ -283,6 +286,35 @@ fn enabled_relays(settings: &Settings) -> Vec { relays::enabled_urls(settings) } +/// Relays always consulted for profile-scoped data (kind 0 metadata, kind 3 +/// follow lists), even when the user has not added them as read relays. +/// +/// Most clients publish profile data to outbox/profile relays (purplepag.es +/// aggregates them network-wide) rather than to the user's note relays, so a +/// follow-list or metadata query limited to the enabled read relays misses +/// real data. Notes queries deliberately keep using only the enabled relays. +pub const PROFILE_RELAYS: &[&str] = &["wss://purplepag.es"]; + +/// Enabled relays plus the always-on profile relays, de-duplicated. +/// +/// Trailing slashes are normalised away so `wss://purplepag.es/` and +/// `wss://purplepag.es` count as the same relay (they are equal to the +/// network), both against each other and against the always-on entries. +pub fn profile_lookup_relays(settings: &Settings) -> Vec { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut urls: Vec = Vec::new(); + for url in enabled_relays(settings) + .into_iter() + .chain(PROFILE_RELAYS.iter().map(|u| (*u).to_string())) + { + let normalised = url.trim_end_matches('/').to_string(); + if seen.insert(normalised.clone()) { + urls.push(normalised); + } + } + urls +} + /// Accumulates notes into a bounded, de-duplicated, newest-first feed. struct FeedBuilder { items: HashMap, @@ -539,16 +571,49 @@ mod tests { assert_eq!(items[0].author, followed.public_key().to_hex()); } - #[tokio::test] - async fn contact_feed_with_no_relays_is_empty() { + #[test] + fn profile_lookup_relays_always_include_the_profile_relays() { + // With no enabled relays the query set still contains the always-on + // profile relays — that is the whole point (follow lists and kind-0 + // usually live there, not on the user's note relays). let settings = Settings { relays: Vec::new(), ..Default::default() }; - let feed = contact_feed(&settings, DEFAULT_LIMIT, "00".repeat(32).as_str()) + assert_eq!( + profile_lookup_relays(&settings), + PROFILE_RELAYS + .iter() + .map(|u| u.to_string()) + .collect::>() + ); + + // Enabled relays pass through, and a profile relay the user already + // added is not duplicated (trailing slash included). + let settings = Settings { + relays: vec![ + crate::settings::RelayConfig::new("wss://notes.example"), + crate::settings::RelayConfig::new(PROFILE_RELAYS[0]), + crate::settings::RelayConfig::new(format!("{}/", PROFILE_RELAYS[0])), + ], + ..Default::default() + }; + let urls = profile_lookup_relays(&settings); + assert_eq!(urls.len(), 2, "no duplicate profile relay: {urls:?}"); + } + + #[tokio::test] + async fn contact_feed_with_invalid_owner_errors_before_network() { + // Owner parsing happens before any relay work, so an invalid key + // errors immediately even though profile relays are always present. + let settings = Settings { + relays: Vec::new(), + ..Default::default() + }; + let err = contact_feed(&settings, DEFAULT_LIMIT, "not-hex") .await - .unwrap(); - assert!(feed.is_empty()); + .expect_err("an invalid hex owner must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::Internal); } #[tokio::test] @@ -575,18 +640,6 @@ mod tests { assert_eq!(err.kind(), crate::errors::ErrorKind::Internal); } - #[tokio::test] - async fn contact_feed_with_invalid_owner_errors() { - let settings = Settings { - relays: Vec::new(), - ..Default::default() - }; - let err = contact_feed(&settings, DEFAULT_LIMIT, "not-hex") - .await - .expect_err("an invalid hex owner must error"); - assert_eq!(err.kind(), crate::errors::ErrorKind::Internal); - } - #[test] fn zero_limit_still_returns_an_empty_feed_without_relays() { let settings = Settings { diff --git a/src/ipc.rs b/src/ipc.rs index 43de978..d013d97 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -359,10 +359,10 @@ pub async fn serve() -> Result<(), AppError> { .vault .profiles .iter() - .filter(|p| { - p.signer_mode == SignerMode::Nip46Client - && profiles::is_generic_pairing_label(&p.label) - }) + // Any row still wearing a create/pairing + // placeholder gets a real name from the network, + // whether its key is local or remote. + .filter(|p| profiles::is_generic_pairing_label(&p.label)) .filter_map(|p| { PublicKey::parse(&p.public_key) .ok() @@ -377,7 +377,9 @@ pub async fn serve() -> Result<(), AppError> { } let relay_urls = { let guard = app.lock().await; - relays::enabled_urls(&guard.settings) + // Metadata lives on profile relays, not just the user's + // read relays — include the always-on profile relays. + crate::feed::profile_lookup_relays(&guard.settings) }; for (npub, identity) in pending { let found = tokio::time::timeout( diff --git a/src/profiles.rs b/src/profiles.rs index 2da0bf1..ce36161 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -559,7 +559,7 @@ pub fn find_stored_profile<'a>( /// never resolved its real identity from the network, so it stays a /// candidate for automatic name backfill on every launch. A user rename /// always falls outside this list and is therefore never overwritten. -pub const GENERIC_PAIRING_LABELS: &[&str] = &["Amber", "Remote Signer"]; +pub const GENERIC_PAIRING_LABELS: &[&str] = &["Amber", "Remote Signer", "My Profile"]; /// True when `label` is one of the generic pairing placeholders. pub fn is_generic_pairing_label(label: &str) -> bool { @@ -947,13 +947,15 @@ mod tests { #[test] fn generic_pairing_labels_gate_the_backfill() { // The background backfill upgrades a row's label ONLY while it still - // wears one of the placeholders the UI assigns at pairing time. + // wears one of the placeholders the UI assigns at pairing or create + // time ("My Profile" is normalise_label's empty-input default). assert!(is_generic_pairing_label("Amber")); assert!(is_generic_pairing_label("Remote Signer")); + assert!(is_generic_pairing_label("My Profile")); // Any real name — fetched or user-typed — is never a candidate, so // automatic enrichment can never overwrite it. assert!(!is_generic_pairing_label("satoshi")); - assert!(!is_generic_pairing_label("My Profile")); + assert!(!is_generic_pairing_label("god is decentralized")); assert!(!is_generic_pairing_label("amber")); assert!(!is_generic_pairing_label("")); }