feat(feed): resolve author names and pictures from kind-0 metadata
This commit is contained in:
parent
cd8b6711a4
commit
a76d8dff4e
4 changed files with 193 additions and 5 deletions
|
|
@ -142,6 +142,10 @@ export interface FeedItem {
|
|||
author: string;
|
||||
/** Bech32 `npub` of the author, for display. */
|
||||
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;
|
||||
/** Unix timestamp the note was created. */
|
||||
created_at: number;
|
||||
|
|
|
|||
|
|
@ -219,12 +219,20 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
|||
<ul className="feed-list">
|
||||
{items.map((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-meta">
|
||||
{item.author_name ? (
|
||||
<span title={item.author_npub}>{item.author_name}</span>
|
||||
) : (
|
||||
<span className="mono" title={item.author_npub}>
|
||||
{shortenNpub(item.author_npub, shorten)}
|
||||
</span>
|
||||
)}
|
||||
<span className="feed-item-time">{formatDate(item.created_at)}</span>
|
||||
{item.relays.length > 1 && (
|
||||
<Badge tone="neutral">{item.relays.length} relays</Badge>
|
||||
|
|
|
|||
|
|
@ -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(<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 () => {
|
||||
const settings = {
|
||||
theme: 'light' as const,
|
||||
|
|
|
|||
162
src/feed.rs
162
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<String>,
|
||||
/// Author picture URL from their latest kind-0, when one was found.
|
||||
pub author_picture: Option<String>,
|
||||
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<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.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue