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).
///
/// 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> {
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<String> {
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.
struct FeedBuilder {
items: HashMap<String, FeedItem>,
@ -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::<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
.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 {

View file

@ -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(

View file

@ -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(""));
}