mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-13 18:28:55 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -375,6 +375,7 @@ goblin:
|
||||
nav_receive: "Получить"
|
||||
nav_settings: "Настройки"
|
||||
activity: "Действия"
|
||||
news: "Новости"
|
||||
empty_title: "Пока нет действий"
|
||||
empty_sub: "Отправьте или получите grin, чтобы начать."
|
||||
recent: "Недавние"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -375,6 +375,7 @@ goblin:
|
||||
nav_receive: "收款"
|
||||
nav_settings: "设置"
|
||||
activity: "动态"
|
||||
news: "新闻"
|
||||
empty_title: "暂无动态"
|
||||
empty_sub: "收发 grin 即可开始。"
|
||||
recent: "最近"
|
||||
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 ``
|
||||
/// 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  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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user