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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
162
src/feed.rs
162
src/feed.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue