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.
This commit is contained in:
2ro
2026-07-04 20:07:13 -04:00
parent e492703a0c
commit ba8e81ef5f
11 changed files with 458 additions and 33 deletions
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -375,6 +375,7 @@ goblin:
nav_receive: "Получить"
nav_settings: "Настройки"
activity: "Действия"
news: "Новости"
empty_title: "Пока нет действий"
empty_sub: "Отправьте или получите grin, чтобы начать."
recent: "Недавние"
+1
View File
@@ -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"
+1
View File
@@ -375,6 +375,7 @@ goblin:
nav_receive: "收款"
nav_settings: "设置"
activity: "动态"
news: "新闻"
empty_title: "暂无动态"
empty_sub: "收发 grin 即可开始。"
recent: "最近"
+90 -1
View File
@@ -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<NewsItem> {
#[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)]
);
}
}
+60 -2
View File
@@ -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);
+204 -30
View File
@@ -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<String>,
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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, wallet: &Wallet, slate_id: &str,
}
}
/// First value of the first tag named `name`, if any.
fn first_tag_value(event: &Event, name: &str) -> Option<String> {
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<NostrService>, 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::<Vec<_>>()
.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<char> = 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<NostrService>, wallet: &Wallet, event: Event) {
// 0. Only gift wraps.
if event.kind != Kind::GiftWrap {
+84
View File
@@ -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<RwLock<Rkv<SafeModeEnvironment>>>,
@@ -42,6 +46,8 @@ pub struct NostrStore {
processed: SingleStore<SafeModeDatabase>,
/// Service settings (last connected time etc).
settings: SingleStore<SafeModeDatabase>,
/// Cached news posts by `d` tag.
news: SingleStore<SafeModeDatabase>,
}
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<NewsItem> {
self.all_json(&self.news)
}
/// The latest news post overall (newest `created_at`).
pub fn latest_news(&self) -> Option<NewsItem> {
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<NewsItem>, incoming: NewsItem, cap: usize) -> Vec<NewsItem> {
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);
}
}
+14
View File
@@ -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()