feat(feed): resolve author names and pictures from kind-0 metadata

This commit is contained in:
Avi 2026-09-23 16:06:19 -05:00
commit a76d8dff4e
4 changed files with 193 additions and 5 deletions

View file

@ -142,6 +142,10 @@ export interface FeedItem {
author: string; author: string;
/** Bech32 `npub` of the author, for display. */ /** Bech32 `npub` of the author, for display. */
author_npub: string; author_npub: string;
/** Author display name from their latest kind-0, when one was found. */
author_name?: string | null;
/** Author picture URL from their latest kind-0, when one was found. */
author_picture?: string | null;
content: string; content: string;
/** Unix timestamp the note was created. */ /** Unix timestamp the note was created. */
created_at: number; created_at: number;

View file

@ -219,12 +219,20 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
<ul className="feed-list"> <ul className="feed-list">
{items.map((item) => ( {items.map((item) => (
<li key={item.id} className="feed-item"> <li key={item.id} className="feed-item">
<Avatar npub={item.author_npub} label={shortenNpub(item.author_npub, shorten)} /> <Avatar
npub={item.author_npub}
label={item.author_name ?? shortenNpub(item.author_npub, shorten)}
picture={item.author_picture ?? null}
/>
<div className="feed-item-body"> <div className="feed-item-body">
<div className="feed-item-meta"> <div className="feed-item-meta">
{item.author_name ? (
<span title={item.author_npub}>{item.author_name}</span>
) : (
<span className="mono" title={item.author_npub}> <span className="mono" title={item.author_npub}>
{shortenNpub(item.author_npub, shorten)} {shortenNpub(item.author_npub, shorten)}
</span> </span>
)}
<span className="feed-item-time">{formatDate(item.created_at)}</span> <span className="feed-item-time">{formatDate(item.created_at)}</span>
{item.relays.length > 1 && ( {item.relays.length > 1 && (
<Badge tone="neutral">{item.relays.length} relays</Badge> <Badge tone="neutral">{item.relays.length} relays</Badge>

View file

@ -22,6 +22,22 @@ describe('FeedScreen', () => {
expect(screen.getByText('2 relays')).toBeInTheDocument(); expect(screen.getByText('2 relays')).toBeInTheDocument();
}); });
it('shows the author name and picture when the feed resolved them', async () => {
const backend = createFakeBackend();
backend.feedItems = backend.feedItems.map((item, index) =>
index === 0
? { ...item, author_name: 'Alice Liddell', author_picture: 'https://example.com/alice.png' }
: item,
);
renderFeed(backend);
renderWithApp(<FeedScreen onNavigate={vi.fn()} />);
expect(await screen.findByText('Alice Liddell')).toBeInTheDocument();
// The avatar image is decorative (empty alt), so query by src.
const avatar = document.querySelector('img[src="https://example.com/alice.png"]');
expect(avatar).not.toBeNull();
});
it('disable relays shows an empty state that can navigate to relays', async () => { it('disable relays shows an empty state that can navigate to relays', async () => {
const settings = { const settings = {
theme: 'light' as const, theme: 'light' as const,

View file

@ -44,6 +44,11 @@ pub struct FeedItem {
pub author: String, pub author: String,
/// Bech32 `npub` of the author, for display. /// Bech32 `npub` of the author, for display.
pub author_npub: String, pub author_npub: String,
/// Author display name from their latest kind-0, when one was found.
/// `None` means "show the npub" — never an error.
pub author_name: Option<String>,
/// Author picture URL from their latest kind-0, when one was found.
pub author_picture: Option<String>,
pub content: String, pub content: String,
/// Unix timestamp the note was created. /// Unix timestamp the note was created.
pub created_at: u64, pub created_at: u64,
@ -181,7 +186,96 @@ async fn aggregate_for(
} }
client.disconnect().await; client.disconnect().await;
Ok(feed.finish()) let mut items = feed.finish();
// Best-effort author enrichment: one batched kind-0 lookup for every
// distinct author, so the feed can show names/pictures instead of bare
// npubs. Runs on a fresh throwaway pool (the client above is already
// disconnected); any failure just leaves the npub fallback in place.
attach_author_metadata(&mut items, &relay_urls).await;
Ok(items)
}
/// How long the batched kind-0 author lookup may take. Short on purpose:
/// names are decoration, and the notes themselves are already in hand.
const AUTHOR_TIMEOUT: Duration = Duration::from_secs(8);
/// Fill `author_name` / `author_picture` for feed items from the authors'
/// latest kind-0 metadata. One batched relay query for all distinct authors;
/// silently does nothing when relays are unreachable, so the npub fallback
/// always survives.
async fn attach_author_metadata(items: &mut [FeedItem], relay_urls: &[String]) {
use std::collections::HashSet;
let authors: Vec<PublicKey> = {
let mut seen = HashSet::new();
items
.iter()
.filter_map(|item| PublicKey::from_hex(&item.author).ok())
.filter(|key| seen.insert(key.to_hex()))
.collect()
};
if authors.is_empty() || relay_urls.is_empty() {
return;
}
let client = Client::builder()
.authenticator(SignerAuthenticator::new(Keys::generate()))
.build();
for url in relay_urls {
let _ = client.add_relay(url.as_str()).await;
}
client.connect().await;
let events = client
.fetch_events(
Filter::new()
.kind(Kind::Metadata)
.authors(authors)
.limit(100),
)
.timeout(AUTHOR_TIMEOUT)
.await
.ok();
client.disconnect().await;
if let Some(events) = events {
apply_author_metadata(items, events.into_iter());
}
}
/// Fold kind-0 events into feed items: newest event per author wins; the
/// display name prefers `display_name` over `name`; blank values stay `None`
/// so the UI falls back to the npub.
fn apply_author_metadata<I>(items: &mut [FeedItem], events: I)
where
I: Iterator<Item = Event>,
{
use std::collections::HashMap;
let mut best: HashMap<String, &Event> = HashMap::new();
let mut order: Vec<Event> = events.collect();
order.sort_by_key(|e| e.created_at);
for event in &order {
best.insert(event.pubkey.to_hex(), event);
}
for item in items.iter_mut() {
let Some(event) = best.get(&item.author) else {
continue;
};
let Ok(meta) = serde_json::from_str::<Metadata>(&event.content) else {
continue;
};
item.author_name = meta
.display_name
.as_deref()
.or(meta.name.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
item.author_picture = meta
.picture
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
}
} }
/// URLs of every enabled relay. /// URLs of every enabled relay.
@ -256,6 +350,8 @@ impl FeedItem {
id, id,
author: event.pubkey.to_hex(), author: event.pubkey.to_hex(),
author_npub, author_npub,
author_name: None,
author_picture: None,
content: event.content.trim().to_string(), content: event.content.trim().to_string(),
created_at: event.created_at.as_secs(), created_at: event.created_at.as_secs(),
relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(), relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(),
@ -300,6 +396,8 @@ mod tests {
id, id,
author: "a".into(), author: "a".into(),
author_npub: "npub1a".into(), author_npub: "npub1a".into(),
author_name: None,
author_picture: None,
content: "c".into(), content: "c".into(),
created_at: created, created_at: created,
relays: vec![], relays: vec![],
@ -335,6 +433,68 @@ mod tests {
assert!(builder.items.is_empty()); assert!(builder.items.is_empty());
} }
#[test]
fn author_metadata_newest_wins_and_blanks_fall_back() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let alice = Keys::generate();
let bob = Keys::generate();
let mut items = vec![
FeedItem {
id: "1".into(),
author: alice.public_key().to_hex(),
author_npub: alice.public_key().to_bech32().unwrap(),
author_name: None,
author_picture: None,
content: "hi".into(),
created_at: 100,
relays: vec![],
},
FeedItem {
id: "2".into(),
author: bob.public_key().to_hex(),
author_npub: bob.public_key().to_bech32().unwrap(),
author_name: None,
author_picture: None,
content: "hey".into(),
created_at: 90,
relays: vec![],
},
];
// Older Alice metadata loses to the newer one; display_name wins.
let old = EventBuilder::new(
Kind::Metadata,
r#"{"name":"A","picture":"https://old.example/a.png"}"#.to_string(),
)
.custom_created_at(Timestamp::from(10))
.finalize_async(&alice)
.await
.unwrap();
let new = EventBuilder::new(
Kind::Metadata,
r#"{"name":"A","display_name":"Alice Liddell","picture":"https://new.example/a.png"}"#.to_string(),
)
.custom_created_at(Timestamp::from(20))
.finalize_async(&alice)
.await
.unwrap();
// Bob's metadata is blank: fallback stays npub.
let blank = EventBuilder::new(Kind::Metadata, r#"{"name":" "}"#.to_string())
.custom_created_at(Timestamp::from(30))
.finalize_async(&bob)
.await
.unwrap();
apply_author_metadata(&mut items, vec![old, new, blank].into_iter());
assert_eq!(items[0].author_name.as_deref(), Some("Alice Liddell"));
assert_eq!(
items[0].author_picture.as_deref(),
Some("https://new.example/a.png")
);
assert!(items[1].author_name.is_none());
assert!(items[1].author_picture.is_none());
});
}
#[tokio::test] #[tokio::test]
async fn limit_stops_collection_when_full() { async fn limit_stops_collection_when_full() {
let mut builder = FeedBuilder::new(2, None); let mut builder = FeedBuilder::new(2, None);