-
- {shortenNpub(item.author_npub, shorten)}
-
+ {item.author_name ? (
+
{item.author_name}
+ ) : (
+
+ {shortenNpub(item.author_npub, shorten)}
+
+ )}
{formatDate(item.created_at)}
{item.relays.length > 1 && (
{item.relays.length} relays
diff --git a/frontend/src/test/FeedScreen.test.tsx b/frontend/src/test/FeedScreen.test.tsx
index bb2a93c..99c3d1f 100644
--- a/frontend/src/test/FeedScreen.test.tsx
+++ b/frontend/src/test/FeedScreen.test.tsx
@@ -22,6 +22,22 @@ describe('FeedScreen', () => {
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(
);
+
+ 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 () => {
const settings = {
theme: 'light' as const,
diff --git a/src/feed.rs b/src/feed.rs
index 2674a62..f876012 100644
--- a/src/feed.rs
+++ b/src/feed.rs
@@ -44,6 +44,11 @@ pub struct FeedItem {
pub author: String,
/// Bech32 `npub` of the author, for display.
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
,
+ /// Author picture URL from their latest kind-0, when one was found.
+ pub author_picture: Option,
pub content: String,
/// Unix timestamp the note was created.
pub created_at: u64,
@@ -181,7 +186,96 @@ async fn aggregate_for(
}
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 = {
+ 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(items: &mut [FeedItem], events: I)
+where
+ I: Iterator- ,
+{
+ use std::collections::HashMap;
+
+ let mut best: HashMap = HashMap::new();
+ let mut order: Vec = 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::(&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.
@@ -256,6 +350,8 @@ impl FeedItem {
id,
author: event.pubkey.to_hex(),
author_npub,
+ author_name: None,
+ author_picture: None,
content: event.content.trim().to_string(),
created_at: event.created_at.as_secs(),
relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(),
@@ -300,6 +396,8 @@ mod tests {
id,
author: "a".into(),
author_npub: "npub1a".into(),
+ author_name: None,
+ author_picture: None,
content: "c".into(),
created_at: created,
relays: vec![],
@@ -335,6 +433,68 @@ mod tests {
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]
async fn limit_stops_collection_when_full() {
let mut builder = FeedBuilder::new(2, None);