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.
This commit is contained in:
Avi 2026-09-30 14:18:33 -05:00
commit aef47dd1ef
3 changed files with 82 additions and 25 deletions

View file

@ -96,9 +96,12 @@ pub async fn profile_feed(
} }
/// Fetch the hex public keys followed by an owner profile (kind 3 contact list). /// 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<Vec<PublicKey>, AppError> { async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result<Vec<PublicKey>, AppError> {
let owner = owner_pubkey(owner_hex)?; let owner = owner_pubkey(owner_hex)?;
let relay_urls = enabled_relays(settings); let relay_urls = profile_lookup_relays(settings);
if relay_urls.is_empty() { if relay_urls.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@ -283,6 +286,35 @@ fn enabled_relays(settings: &Settings) -> Vec<String> {
relays::enabled_urls(settings) 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<String> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut urls: Vec<String> = 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. /// Accumulates notes into a bounded, de-duplicated, newest-first feed.
struct FeedBuilder { struct FeedBuilder {
items: HashMap<String, FeedItem>, items: HashMap<String, FeedItem>,
@ -539,16 +571,49 @@ mod tests {
assert_eq!(items[0].author, followed.public_key().to_hex()); assert_eq!(items[0].author, followed.public_key().to_hex());
} }
#[tokio::test] #[test]
async fn contact_feed_with_no_relays_is_empty() { 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 { let settings = Settings {
relays: Vec::new(), relays: Vec::new(),
..Default::default() ..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::<Vec<_>>()
);
// 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 .await
.unwrap(); .expect_err("an invalid hex owner must error");
assert!(feed.is_empty()); assert_eq!(err.kind(), crate::errors::ErrorKind::Internal);
} }
#[tokio::test] #[tokio::test]
@ -575,18 +640,6 @@ mod tests {
assert_eq!(err.kind(), crate::errors::ErrorKind::Internal); 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] #[test]
fn zero_limit_still_returns_an_empty_feed_without_relays() { fn zero_limit_still_returns_an_empty_feed_without_relays() {
let settings = Settings { let settings = Settings {

View file

@ -359,10 +359,10 @@ pub async fn serve() -> Result<(), AppError> {
.vault .vault
.profiles .profiles
.iter() .iter()
.filter(|p| { // Any row still wearing a create/pairing
p.signer_mode == SignerMode::Nip46Client // placeholder gets a real name from the network,
&& profiles::is_generic_pairing_label(&p.label) // whether its key is local or remote.
}) .filter(|p| profiles::is_generic_pairing_label(&p.label))
.filter_map(|p| { .filter_map(|p| {
PublicKey::parse(&p.public_key) PublicKey::parse(&p.public_key)
.ok() .ok()
@ -377,7 +377,9 @@ pub async fn serve() -> Result<(), AppError> {
} }
let relay_urls = { let relay_urls = {
let guard = app.lock().await; 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 { for (npub, identity) in pending {
let found = tokio::time::timeout( let found = tokio::time::timeout(

View file

@ -559,7 +559,7 @@ pub fn find_stored_profile<'a>(
/// never resolved its real identity from the network, so it stays a /// never resolved its real identity from the network, so it stays a
/// candidate for automatic name backfill on every launch. A user rename /// candidate for automatic name backfill on every launch. A user rename
/// always falls outside this list and is therefore never overwritten. /// 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. /// True when `label` is one of the generic pairing placeholders.
pub fn is_generic_pairing_label(label: &str) -> bool { pub fn is_generic_pairing_label(label: &str) -> bool {
@ -947,13 +947,15 @@ mod tests {
#[test] #[test]
fn generic_pairing_labels_gate_the_backfill() { fn generic_pairing_labels_gate_the_backfill() {
// The background backfill upgrades a row's label ONLY while it still // 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("Amber"));
assert!(is_generic_pairing_label("Remote Signer")); 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 // Any real name — fetched or user-typed — is never a candidate, so
// automatic enrichment can never overwrite it. // automatic enrichment can never overwrite it.
assert!(!is_generic_pairing_label("satoshi")); 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("amber"));
assert!(!is_generic_pairing_label("")); assert!(!is_generic_pairing_label(""));
} }