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:
parent
eea6f0e6b1
commit
aef47dd1ef
3 changed files with 82 additions and 25 deletions
89
src/feed.rs
89
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).
|
/// 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 {
|
||||||
|
|
|
||||||
12
src/ipc.rs
12
src/ipc.rs
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(""));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue