From ba8e81ef5fa68aee5e543a915259c073182089a1 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:07:13 -0400 Subject: [PATCH] Goblin Build 136 - home news panel, faster send confirmation Home now shows the latest post from the Goblin news key (kind 30023) in a card with tappable links; the panel stays hidden until a post is seen. Desktop Home widens to use the available space. Send confirms faster. --- locales/de.yml | 1 + locales/en.yml | 1 + locales/fr.yml | 1 + locales/ru.yml | 1 + locales/tr.yml | 1 + locales/zh-CN.yml | 1 + src/gui/views/goblin/data.rs | 91 +++++++++++++- src/gui/views/goblin/mod.rs | 62 +++++++++- src/nostr/client.rs | 234 ++++++++++++++++++++++++++++++----- src/nostr/store.rs | 84 +++++++++++++ src/nostr/types.rs | 14 +++ 11 files changed, 458 insertions(+), 33 deletions(-) diff --git a/locales/de.yml b/locales/de.yml index a28441e3..bad17339 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -375,6 +375,7 @@ goblin: nav_receive: "Empfangen" nav_settings: "Einstellungen" activity: "Aktivität" + news: "Neuigkeiten" empty_title: "Noch keine Aktivität" empty_sub: "Sende oder empfange grin, um zu starten." recent: "Zuletzt" diff --git a/locales/en.yml b/locales/en.yml index 182b5afd..c51eee6b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -375,6 +375,7 @@ goblin: nav_receive: "Receive" nav_settings: "Settings" activity: "Activity" + news: "News" empty_title: "No activity yet" empty_sub: "Send or receive grin to get started." recent: "Recent" diff --git a/locales/fr.yml b/locales/fr.yml index 1b5452b6..d7a7f3a9 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -375,6 +375,7 @@ goblin: nav_receive: "Recevoir" nav_settings: "Réglages" activity: "Activité" + news: "Actualités" empty_title: "Aucune activité" empty_sub: "Envoyez ou recevez des grin pour commencer." recent: "Récent" diff --git a/locales/ru.yml b/locales/ru.yml index 607f8eac..71898aa3 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -375,6 +375,7 @@ goblin: nav_receive: "Получить" nav_settings: "Настройки" activity: "Действия" + news: "Новости" empty_title: "Пока нет действий" empty_sub: "Отправьте или получите grin, чтобы начать." recent: "Недавние" diff --git a/locales/tr.yml b/locales/tr.yml index 92bd64b9..3fa1c7ce 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -375,6 +375,7 @@ goblin: nav_receive: "Al" nav_settings: "Ayarlar" activity: "Etkinlik" + news: "Haberler" empty_title: "Henüz etkinlik yok" empty_sub: "Başlamak için grin gönder ya da al." recent: "Son işlemler" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 20cbff3c..5c94d40c 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -375,6 +375,7 @@ goblin: nav_receive: "收款" nav_settings: "设置" activity: "动态" + news: "新闻" empty_title: "暂无动态" empty_sub: "收发 grin 即可开始。" recent: "最近" diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index ffb75f28..07f825e2 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -16,7 +16,7 @@ use grin_wallet_libwallet::TxLogEntryType; -use crate::nostr::{Contact, NostrSendStatus, NostrStore, TxNostrMeta}; +use crate::nostr::{Contact, NewsItem, NostrSendStatus, NostrStore, TxNostrMeta}; use crate::wallet::Wallet; use crate::wallet::types::WalletTx; @@ -378,3 +378,92 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin hits.truncate(limit); hits } + +/// The latest cached news post for the Home panel, or `None` (panel hides). +/// `GOBLIN_FAKE_NEWS=1` injects a fixed item in debug builds so the panel can be +/// screenshotted without a live relay feed. +pub fn news_latest(wallet: &Wallet) -> Option { + #[cfg(debug_assertions)] + if std::env::var("GOBLIN_FAKE_NEWS").is_ok() { + return Some(NewsItem { + d: "fake".to_string(), + created_at: 0, + title: "Goblin Build 135".to_string(), + summary: "Tor transport is live. Read more: https://docs.goblin.st".to_string(), + }); + } + wallet.nostr_service()?.store.latest_news() +} + +/// Split a plain-text summary into (segment, is_url) runs so http(s) URLs render +/// as tappable links and the rest as plain labels. Trailing sentence +/// punctuation is trimmed off a URL so "…goblin.st." doesn't link the dot. +pub fn split_urls(s: &str) -> Vec<(String, bool)> { + let mut out = Vec::new(); + let mut rest = s; + while let Some(idx) = rest.find("http") { + let candidate = &rest[idx..]; + if candidate.starts_with("http://") || candidate.starts_with("https://") { + if idx > 0 { + out.push((rest[..idx].to_string(), false)); + } + let end = candidate + .find(char::is_whitespace) + .unwrap_or(candidate.len()); + let mut url = &candidate[..end]; + while let Some(last) = url.chars().last() { + if matches!(last, '.' | ',' | ')' | ']' | '}' | '!' | '?' | ';' | ':') { + url = &url[..url.len() - last.len_utf8()]; + } else { + break; + } + } + out.push((url.to_string(), true)); + rest = &candidate[url.len()..]; + } else { + // A bare "http" that isn't a scheme; emit it as text and move past it. + let split_at = idx + 4; + out.push((rest[..split_at].to_string(), false)); + rest = &rest[split_at..]; + } + } + if !rest.is_empty() { + out.push((rest.to_string(), false)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_urls_isolates_links() { + let segs = split_urls("Tor is live. Read more: https://docs.goblin.st now"); + assert_eq!( + segs, + vec![ + ("Tor is live. Read more: ".to_string(), false), + ("https://docs.goblin.st".to_string(), true), + (" now".to_string(), false), + ] + ); + } + + #[test] + fn split_urls_trims_trailing_punctuation_and_handles_no_url() { + let segs = split_urls("See https://x.io."); + assert_eq!( + segs, + vec![ + ("See ".to_string(), false), + ("https://x.io".to_string(), true), + (".".to_string(), false), + ] + ); + assert_eq!( + split_urls("plain text"), + vec![("plain text".to_string(), false)] + ); + } +} diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 9050b36d..293d8594 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -33,7 +33,7 @@ use crate::gui::views::{Content, TextEdit, View}; use crate::wallet::Wallet; use crate::wallet::types::WalletData; -use self::data::{ActivityItem, activity_items, recent_peers}; +use self::data::{ActivityItem, activity_items, news_latest, recent_peers, split_urls}; use self::send::SendFlow; use self::widgets as w; @@ -534,7 +534,16 @@ impl GoblinWalletView { ..Default::default() }) .show_inside(ui, |ui| { - w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| match self.tab { + // Desktop Home fills the window (up to a readable max) instead of + // sitting in the narrow 1.2x column with dead space around it — the news + // panel and the rest of home then use the available width. Every other + // tab, and all of mobile, keeps the original narrow column. + let col_width = if wide_desktop && self.tab == Tab::Home { + Content::SIDE_PANEL_WIDTH * 2.4 + } else { + Content::SIDE_PANEL_WIDTH * 1.2 + }; + w::centered_column(ui, col_width, |ui| match self.tab { Tab::Home => self.home_ui(ui, wallet, cb, wide_desktop), Tab::Pay => self.pay_ui(ui, wallet, cb), Tab::Activity => self.activity_ui(ui, wallet, cb), @@ -1026,6 +1035,9 @@ impl GoblinWalletView { } ui.add_space(24.0); + // Latest news post (hidden entirely when none seen yet). + self.news_panel_ui(ui, wallet); + // Recent peers strip. self.peers_strip_ui(ui, wallet, "goblin_peers_home"); @@ -1048,6 +1060,52 @@ impl GoblinWalletView { }); } + /// Latest news post from the Goblin news key. Title + summary in a card, with + /// any http(s) URL in the summary rendered as a tappable link. Renders nothing + /// (early return) when no post has been cached yet — no empty state. + fn news_panel_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let Some(news) = news_latest(wallet) else { + return; + }; + let t = theme::tokens(); + w::kicker(ui, &t!("goblin.home.news")); + ui.add_space(8.0); + w::card(ui, |ui| { + // Span the full content width like the balance/activity rows so the + // panel reads as a band, not a content-hugging chip. + ui.set_min_width(ui.available_width()); + if !news.title.is_empty() { + ui.add( + egui::Label::new( + RichText::new(&news.title) + .font(FontId::new(16.0, fonts::semibold())) + .color(t.surface_text), + ) + .truncate(), + ); + } + if !news.summary.is_empty() { + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (seg, is_url) in split_urls(&news.summary) { + if is_url { + // hyperlink opens via ctx.open_url, same as open_url(). + ui.hyperlink(seg); + } else { + ui.label( + RichText::new(seg) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + } + } + }); + } + }); + ui.add_space(24.0); + } + /// Horizontal recent-contacts strip; tapping one starts a prefilled send. fn peers_strip_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, salt: &str) { let peers = recent_peers(wallet, 8); diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 5dd6b8a4..78797117 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -18,8 +18,8 @@ use grin_core::core::amount_to_hr_string; use log::{error, info, warn}; use nostr_sdk::{ - Client, Event, EventBuilder, Filter, Keys, Kind, Metadata, PublicKey, RelayPoolNotification, - RelayStatus, SubscriptionId, Tag, TagKind, Timestamp, ToBech32, + Client, Event, EventBuilder, Filter, FromBech32, Keys, Kind, Metadata, PublicKey, + RelayPoolNotification, RelayStatus, SubscriptionId, Tag, TagKind, Timestamp, ToBech32, }; use parking_lot::{Mutex, RwLock}; use std::collections::HashMap; @@ -52,6 +52,13 @@ pub struct NostrProfile { /// duplicate REQs on the relays. const GIFTWRAP_SUB: &str = "goblin-giftwrap"; +/// The Goblin news publisher (kind 30023 long-form). The Home news panel shows +/// this key's latest post, fetched from our own relay set. +const NEWS_NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely"; +/// Stable subscription id for the news feed (same replace-not-duplicate reason +/// as [`GIFTWRAP_SUB`]). +const NEWS_SUB: &str = "goblin-news"; + /// Subscription look-back window beyond the last connection time: gift wrap /// timestamps are randomized up to 2 days into the past (NIP-59), use 3 days. const LOOKBACK_SECS: i64 = 3 * 86_400; @@ -614,29 +621,41 @@ impl NostrService { .map_err(|e| format!("send failed: {e}"))?; let event_id = res.val; - // DELIVERY CONFIRM (money-path safety), reconnect-resilient. `send_*_to` - // returned success the moment the wrap was accepted for delivery to the - // relays — that IS write-level evidence, but not proof a relay the RECIPIENT - // reads has stored it. Confirm the way the recipient's inbox retrieves it: - // query {kinds:[1059], "#p":[receiver]} pinned to THIS wrap's id, over the - // SAME target set — which now always includes our own advertised relays - // (the shared-relay floor the recipient also reads; see `send_targets`). - // First-event-wins via `stream_events_from` returns the instant ANY - // targeted relay serves the wrap, so the spinner clears as fast as it lands. - // The loop retries across transient transport drops within the budget - // (arti rebuilds circuits during the CONFIRM_GAP sleeps), so a flapping - // onion doesn't defeat a wrap that actually landed. - // - // Two outcomes, NEVER a hard failure here (we already hold write-evidence): - // * confirmed -> Ok, report Sent (strongest evidence). - // * unconfirmed within the budget -> Ok as sent-PENDING, logged. Over a - // flapping onion the read-back can't complete even though the write - // landed; a hard error would mark the tx SendFailed, which `reconcile` - // re-dispatches -> DUPLICATE wraps + a stuck "sending" spinner. Instead - // the tx moves to AwaitingS2 (which `reconcile` never re-dispatches) and - // the normal S2-wait / expiry path resolves it. Only a genuine send - // FAILURE (the `send_*_to` error above, BEFORE this loop) ever - // re-dispatches — so we never claim Sent with zero evidence. + // The write already succeeded (a relay accepted the wrap for delivery), + // which IS the send-level evidence the UI waits on — so return Sent NOW at + // write-ack. The read-back delivery-confirm below is ADVISORY only (it + // never changes the returned id and never marks the tx failed), so it runs + // detached in the background: it keeps its logging/retry behavior without + // pinning the spinner for up to CONFIRM_TIMEOUT after the wrap has landed. + { + let client = client.clone(); + let urls = urls.clone(); + tokio::spawn(async move { + Self::confirm_delivery(&client, urls, receiver, event_id).await; + }); + } + Ok(event_id.to_hex()) + } + + /// Advisory delivery-confirm (money-path safety), reconnect-resilient, run in + /// the background AFTER the send returns. `send_*_to` returned success the + /// moment the wrap was accepted for delivery to the relays — that IS + /// write-level evidence, but not proof a relay the RECIPIENT reads has stored + /// it. Confirm the way the recipient's inbox retrieves it: query + /// {kinds:[1059], "#p":[receiver]} pinned to THIS wrap's id, over the SAME + /// target set — which always includes our own advertised relays (the + /// shared-relay floor the recipient also reads; see `send_targets`). The loop + /// retries across transient transport drops within the budget (arti rebuilds + /// circuits during the CONFIRM_GAP sleeps), so a flapping onion doesn't defeat + /// a wrap that actually landed. It NEVER fails the tx — an unconfirmed wrap + /// simply waits for S2 / expiry (a hard failure would re-dispatch DUPLICATE + /// wraps); this is purely a logged observation now that the UI no longer waits. + async fn confirm_delivery( + client: &Client, + urls: Vec, + receiver: PublicKey, + event_id: nostr_sdk::EventId, + ) { use futures::StreamExt; let confirm_filter = Filter::new() .kind(Kind::GiftWrap) @@ -649,7 +668,7 @@ impl NostrService { .stream_events_from(urls.clone(), confirm_filter.clone(), CONFIRM_POLL) .await && stream.next().await.is_some() { - return Ok(event_id.to_hex()); + return; } if tokio::time::Instant::now() >= confirm_deadline { warn!( @@ -659,7 +678,7 @@ impl NostrService { event_id.to_hex(), CONFIRM_TIMEOUT.as_secs() ); - return Ok(event_id.to_hex()); + return; } tokio::time::sleep(CONFIRM_GAP).await; } @@ -1025,6 +1044,16 @@ async fn run_service(svc: Arc, wallet: Wallet) { .pubkey(svc.public_key()) .since(Timestamp::from_secs(since)); + // News feed: the owner's kind-30023 long-form posts on our own relay set. + // Kept owned like `filter` for the re-subscribe after a tunnel reselect. + let news_pk = PublicKey::from_bech32(NEWS_NPUB).ok(); + let news_filter = news_pk.map(|pk| { + Filter::new() + .kind(Kind::LongFormTextNote) + .author(pk) + .limit(4) + }); + if let Ok(events) = client .fetch_events_from(&relays, filter.clone(), FETCH_TIMEOUT) .await @@ -1034,6 +1063,13 @@ async fn run_service(svc: Arc, wallet: Wallet) { handle_wrap(&svc, &wallet, event).await; } } + if let (Some(pk), Some(nf)) = (news_pk, news_filter.clone()) + && let Ok(events) = client.fetch_events_from(&relays, nf, FETCH_TIMEOUT).await + { + for event in events.into_iter() { + handle_news(&svc, pk, event).await; + } + } // Stable-id subscription so a re-subscribe after a tunnel reselect replaces // rather than duplicates it. Keep `filter` owned for that re-subscribe. if let Err(e) = client @@ -1047,6 +1083,13 @@ async fn run_service(svc: Arc, wallet: Wallet) { { error!("nostr: subscribe failed: {e}"); } + if let Some(nf) = news_filter.clone() + && let Err(e) = client + .subscribe_with_id_to(&relays, SubscriptionId::new(NEWS_SUB), nf, None) + .await + { + error!("nostr: news subscribe failed: {e}"); + } // Re-dispatch pending outgoing messages after restart. reconcile(&svc, &wallet).await; @@ -1098,7 +1141,13 @@ async fn run_service(svc: Arc, wallet: Wallet) { notification = notifications.recv() => { match notification { Ok(RelayPoolNotification::Event { event, .. }) => { - handle_wrap(&svc, &wallet, *event).await; + // News long-form posts and gift wraps ride the same feed; + // route by kind (handle_wrap ignores non-1059 anyway). + if let Some(pk) = news_pk && event.kind == Kind::LongFormTextNote { + handle_news(&svc, pk, *event).await; + } else { + handle_wrap(&svc, &wallet, *event).await; + } } Ok(_) => {} Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { @@ -1117,7 +1166,7 @@ async fn run_service(svc: Arc, wallet: Wallet) { let generation = crate::tor::tunnel_generation(); if generation != dial_gen { info!("nostr: tunnel reselected (gen {dial_gen} -> {generation}); re-dialing relays over the new exit"); - redial_on_new_tunnel(&client, &relays, &filter).await; + redial_on_new_tunnel(&client, &relays, &filter, news_filter.as_ref()).await; dial_gen = generation; } let connected = relays_connected(&client).await; @@ -1204,7 +1253,12 @@ async fn connect_relays(client: &Client, urls: &[String]) { /// never duplicates) so we never silently stop receiving. Bounded by /// nostr-sdk's own connect timeouts — no busy loop; the generation-aware re-dial /// is ours, the per-relay reconnect backoff is the pool's. -async fn redial_on_new_tunnel(client: &Client, relays: &[String], filter: &Filter) { +async fn redial_on_new_tunnel( + client: &Client, + relays: &[String], + filter: &Filter, + news_filter: Option<&Filter>, +) { // Close the stale sockets so nostr-sdk re-dials through the current tunnel // (the transport grabs the freshly-selected exit on each new connect). client.disconnect().await; @@ -1223,6 +1277,13 @@ async fn redial_on_new_tunnel(client: &Client, relays: &[String], filter: &Filte { error!("nostr: re-subscribe after reselect failed: {e}"); } + if let Some(nf) = news_filter + && let Err(e) = client + .subscribe_with_id_to(relays, SubscriptionId::new(NEWS_SUB), nf.clone(), None) + .await + { + error!("nostr: news re-subscribe after reselect failed: {e}"); + } } /// True when at least one relay has completed its handshake. @@ -1517,6 +1578,119 @@ fn handle_request_void(svc: &Arc, wallet: &Wallet, slate_id: &str, } } +/// First value of the first tag named `name`, if any. +fn first_tag_value(event: &Event, name: &str) -> Option { + event.tags.iter().find_map(|t| { + let parts = t.as_slice(); + if parts.first().map(|s| s.as_str()) == Some(name) { + parts.get(1).cloned() + } else { + None + } + }) +} + +/// Ingest one kind-30023 news post from the Goblin news key and cache it (the +/// store dedupes newest-per-`d`). Guards kind + author so a stray event on the +/// news subscription can't spoof the panel. +async fn handle_news(svc: &Arc, news_pk: PublicKey, event: Event) { + if event.kind != Kind::LongFormTextNote || event.pubkey != news_pk { + return; + } + let d = first_tag_value(&event, "d").unwrap_or_default(); + let title = first_tag_value(&event, "title").unwrap_or_default(); + let summary = news_summary_text( + first_tag_value(&event, "summary").as_deref(), + &event.content, + ); + svc.store.save_news(NewsItem { + d, + created_at: event.created_at.as_secs() as i64, + title, + summary, + }); +} + +/// The panel's summary line: the `summary` tag when present, otherwise the first +/// couple of lines of the markdown content flattened to plain text. Capped to a +/// sensible length so the panel stays ~two lines. No markdown is ever rendered. +fn news_summary_text(summary_tag: Option<&str>, content: &str) -> String { + if let Some(s) = summary_tag { + let s = s.trim(); + if !s.is_empty() { + return truncate_summary(s); + } + } + let plain = strip_markdown_inline(content); + let joined = plain + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .take(2) + .collect::>() + .join(" "); + truncate_summary(&joined) +} + +/// Cap a summary to ~160 chars on a char boundary, adding an ellipsis. +fn truncate_summary(s: &str) -> String { + const MAX: usize = 160; + if s.chars().count() <= MAX { + return s.to_string(); + } + let head: String = s.chars().take(MAX).collect(); + format!("{}…", head.trim_end()) +} + +/// Strip inline markdown for the fallback summary: drop image `![alt](url)` +/// entirely, reduce link `[text](url)` to its text, and remove common emphasis / +/// heading markers. Deliberately minimal — the owner usually sets the summary +/// tag, so this only runs as a fallback. +fn strip_markdown_inline(s: &str) -> String { + let chars: Vec = s.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < chars.len() { + match chars[i] { + '!' if chars.get(i + 1) == Some(&'[') => { + // Image: drop ![alt](url) wholesale. + i += 2; + while i < chars.len() && chars[i] != ']' { + i += 1; + } + i += 1; // past ']' + if chars.get(i) == Some(&'(') { + while i < chars.len() && chars[i] != ')' { + i += 1; + } + i += 1; // past ')' + } + } + '[' => { + // Link: keep the text, drop the (url). + i += 1; + while i < chars.len() && chars[i] != ']' { + out.push(chars[i]); + i += 1; + } + i += 1; // past ']' + if chars.get(i) == Some(&'(') { + while i < chars.len() && chars[i] != ')' { + i += 1; + } + i += 1; // past ')' + } + } + '#' | '*' | '`' | '>' | '_' => i += 1, + c => { + out.push(c); + i += 1; + } + } + } + out +} + async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { // 0. Only gift wraps. if event.kind != Kind::GiftWrap { diff --git a/src/nostr/store.rs b/src/nostr/store.rs index 10c43ade..518880b4 100644 --- a/src/nostr/store.rs +++ b/src/nostr/store.rs @@ -29,6 +29,10 @@ use crate::nostr::types::*; /// Keys are processed-event markers older than this get pruned (30 days). const PROCESSED_TTL_SECS: i64 = 30 * 86_400; +/// Cap on stored news posts (newest kept, older pruned) — the panel only ever +/// shows the latest, so this is just a small archive bound. +const NEWS_CAP: usize = 8; + /// Nostr metadata archive for a wallet. pub struct NostrStore { env: Arc>>, @@ -42,6 +46,8 @@ pub struct NostrStore { processed: SingleStore, /// Service settings (last connected time etc). settings: SingleStore, + /// Cached news posts by `d` tag. + news: SingleStore, } impl NostrStore { @@ -75,6 +81,7 @@ impl NostrStore { let settings = k .open_single("nostr_settings", StoreOptions::create()) .unwrap(); + let news = k.open_single("nostr_news", StoreOptions::create()).unwrap(); Self { env, tx_meta, @@ -82,6 +89,7 @@ impl NostrStore { requests, processed, settings, + news, } } @@ -288,6 +296,28 @@ impl NostrStore { let _ = writer.commit(); } + // ── news ──────────────────────────────────────────────────────────────── + + pub fn all_news(&self) -> Vec { + self.all_json(&self.news) + } + + /// The latest news post overall (newest `created_at`). + pub fn latest_news(&self) -> Option { + self.all_news().into_iter().max_by_key(|n| n.created_at) + } + + /// Store a news post: newest-`created_at`-per-`d` wins, capped to the newest + /// `NEWS_CAP` entries (older pruned). Keyed by `d`, so the store holds one + /// row per addressable post. + pub fn save_news(&self, item: NewsItem) { + let merged = reconcile_news(self.all_news(), item, NEWS_CAP); + self.clear(&self.news); + for n in &merged { + self.put_json(&self.news, &n.d, n); + } + } + // ── archive control (user-facing) ─────────────────────────────────────── /// Export the whole archive as a JSON document. @@ -309,3 +339,57 @@ impl NostrStore { self.clear(&self.processed); } } + +/// Merge an incoming news post into the stored set: newest `created_at` wins +/// per `d`, then keep only the newest `cap` overall. Pure so it's unit-testable +/// without the rkv env. +fn reconcile_news(mut all: Vec, incoming: NewsItem, cap: usize) -> Vec { + if let Some(existing) = all.iter_mut().find(|n| n.d == incoming.d) { + if incoming.created_at >= existing.created_at { + *existing = incoming; + } + } else { + all.push(incoming); + } + all.sort_by_key(|n| std::cmp::Reverse(n.created_at)); + all.truncate(cap); + all +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(d: &str, created_at: i64) -> NewsItem { + NewsItem { + d: d.to_string(), + created_at, + title: format!("t{created_at}"), + summary: String::new(), + } + } + + #[test] + fn newest_per_d_wins_and_cap_prunes() { + // Same d, newer created_at replaces (and carries the newer title). + let start = vec![item("a", 100)]; + let merged = reconcile_news(start, item("a", 200), 8); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].created_at, 200); + assert_eq!(merged[0].title, "t200"); + + // Same d, OLDER created_at is ignored. + let merged = reconcile_news(merged, item("a", 150), 8); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].created_at, 200); + + // Distinct d accumulate, newest first, capped to `cap`. + let mut all = vec![]; + for i in 0..10 { + all = reconcile_news(all, item(&format!("d{i}"), i as i64), 3); + } + assert_eq!(all.len(), 3); + assert_eq!(all[0].created_at, 9); + assert_eq!(all[2].created_at, 7); + } +} diff --git a/src/nostr/types.rs b/src/nostr/types.rs index fdc08293..4027711f 100644 --- a/src/nostr/types.rs +++ b/src/nostr/types.rs @@ -146,6 +146,20 @@ pub struct PaymentRequest { pub status: RequestStatus, } +/// A cached news post (kind 30023 long-form) from the Goblin news key, shown +/// in the Home news panel. Only the fields the panel needs are persisted. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct NewsItem { + /// The addressable `d` tag (replaceable-event identifier); dedupe key. + pub d: String, + /// Event `created_at` (seconds); newest per `d` wins, newest overall shows. + pub created_at: i64, + /// The post `title` tag. + pub title: String, + /// Plain-text summary (the `summary` tag, or a stripped content fallback). + pub summary: String, +} + /// Current unix time in seconds. pub fn unix_time() -> i64 { std::time::SystemTime::now()