From 1141f97b22454a139deeeb472fb97dbbd0286ce3 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:29:21 -0400 Subject: [PATCH] goblin: date the home news panel (ISO 8601) and guard title overflow Show the article date first in the Home news card, formatted YYYY-MM-DD in UTC (never a US M/D/Y, day only, no time). The date is sourced from the NIP-23 published_at tag when present, else the 30023 event created_at; a new optional published_at field on NewsItem threads it through the store and the news handler. Guard the title against clipping two ways: a hard NEWS_TITLE_MAX_CHARS cap (48) that ellipsizes an over-long title predictably, plus measured shrink-to-fit that steps the semibold font from 16pt down to a 12pt floor so the title stays on one line within the card width on a 390px phone, backed by truncate for the pathological narrow case. --- src/gui/views/goblin/data.rs | 65 ++++++++++++++++++++++++++++++++++++ src/gui/views/goblin/mod.rs | 47 ++++++++++++++++++++++++-- src/nostr/client.rs | 3 ++ src/nostr/store.rs | 1 + src/nostr/types.rs | 6 ++++ 5 files changed, 120 insertions(+), 2 deletions(-) diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index 9ecbfe3b..bcab5048 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -406,6 +406,7 @@ fn news_pool(wallet: &Wallet) -> Vec { summary: "Private grin payments over Tor. Read more: https://docs.goblin.st" .to_string(), lang: None, + published_at: Some(1_782_864_000), // 2026-07-01 UTC }, NewsItem { d: "welcome-de".to_string(), @@ -414,6 +415,7 @@ fn news_pool(wallet: &Wallet) -> Vec { summary: "Private Grin-Zahlungen über Tor. Mehr dazu: https://docs.goblin.st" .to_string(), lang: None, + published_at: Some(1_782_864_000), // 2026-07-01 UTC }, ]; } @@ -475,6 +477,39 @@ pub fn news_display_title(title: &str) -> String { } } +/// Format a unix timestamp (seconds) as an ISO-8601 calendar date in UTC +/// (`YYYY-MM-DD`), never a US `M/D/Y`, and date only (no time-of-day, unlike the +/// activity feed). Dates the Home news panel. An out-of-range stamp falls back to +/// the epoch date rather than panicking. +pub fn news_date_iso(ts: i64) -> String { + use chrono::{TimeZone, Utc}; + Utc.timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "1970-01-01".to_string()) +} + +/// The hard character budget for a Home news title before it ellipsizes. Titles +/// up to ~34 chars sit at the full 16pt on a 390px phone; between there and this +/// cap the panel shrinks the font to keep one line; past it they are ellipsized. +/// This is the author's predictable writing budget. +pub const NEWS_TITLE_MAX_CHARS: usize = 48; + +/// A news title clamped to [`NEWS_TITLE_MAX_CHARS`], ellipsizing (`…`) past it so +/// an over-long title is handled predictably rather than relying on layout alone. +/// The shrink-to-fit font sizing in the panel is the second, screen-width-aware +/// half of the guardrail. Clamps on `char` boundaries so multi-byte titles are +/// never split mid-codepoint. +pub fn news_title_clamped(title: &str) -> String { + let chars: Vec = title.chars().collect(); + if chars.len() > NEWS_TITLE_MAX_CHARS { + let keep = NEWS_TITLE_MAX_CHARS.saturating_sub(1); + format!("{}…", chars[..keep].iter().collect::()) + } else { + title.to_string() + } +} + /// True for a two-letter ASCII-alphabetic language code. fn is_lang_code(s: &str) -> bool { s.len() == 2 && s.chars().all(|c| c.is_ascii_alphabetic()) @@ -579,6 +614,7 @@ mod tests { title: title.to_string(), summary: String::new(), lang: lang.map(|s| s.to_string()), + published_at: None, } } @@ -630,6 +666,35 @@ mod tests { assert_eq!(news_display_title("Build 137 [beta]"), "Build 137 [beta]"); } + #[test] + fn date_is_iso_utc_day_only() { + // 2026-07-01 00:00:00 UTC. + assert_eq!(news_date_iso(1_782_864_000), "2026-07-01"); + // A within-the-day stamp still yields the same calendar date (no time). + assert_eq!(news_date_iso(1_782_864_000 + 3600 * 13 + 59), "2026-07-01"); + // Epoch. + assert_eq!(news_date_iso(0), "1970-01-01"); + } + + #[test] + fn title_clamped_ellipsizes_past_max() { + // Short titles pass through untouched. + let short = "News in Your Language"; + assert_eq!(news_title_clamped(short), short); + // Exactly the cap is untouched. + let at_cap = "x".repeat(NEWS_TITLE_MAX_CHARS); + assert_eq!(news_title_clamped(&at_cap), at_cap); + // One over the cap ellipsizes to exactly the cap length (… included). + let over = "y".repeat(NEWS_TITLE_MAX_CHARS + 10); + let clamped = news_title_clamped(&over); + assert_eq!(clamped.chars().count(), NEWS_TITLE_MAX_CHARS); + assert!(clamped.ends_with('…')); + // Multi-byte titles clamp on char boundaries (no panic / no split codepoint). + let cjk = "语".repeat(NEWS_TITLE_MAX_CHARS + 5); + let clamped = news_title_clamped(&cjk); + assert_eq!(clamped.chars().count(), NEWS_TITLE_MAX_CHARS); + } + #[test] fn select_matches_locale_then_falls_back_to_english() { let pool = vec![ diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index da2b7550..4714c226 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -1086,11 +1086,26 @@ impl GoblinWalletView { // 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()); + // Date first, ISO 8601 (YYYY-MM-DD, UTC). Dated by the article's + // published_at tag when present, else the event's created_at. + let stamp = news.published_at.unwrap_or(news.created_at); + ui.label( + RichText::new(data::news_date_iso(stamp)) + .font(FontId::new(12.0, fonts::medium())) + .color(t.surface_text_dim), + ); if !news.title.is_empty() { + ui.add_space(2.0); + // Title guardrail: hard-cap the length (predictable ellipsis past + // NEWS_TITLE_MAX_CHARS) then shrink the font to fit the card width on + // one line down to a 12pt floor, so a title never clips. `.truncate()` + // backs the floor for the pathological narrow case. + let title = data::news_title_clamped(&news.title); + let pt = fit_news_title_pt(ui, &title, ui.available_width()); ui.add( egui::Label::new( - RichText::new(&news.title) - .font(FontId::new(16.0, fonts::semibold())) + RichText::new(&title) + .font(FontId::new(pt, fonts::semibold())) .color(t.surface_text), ) .truncate(), @@ -5292,3 +5307,31 @@ fn hex_of(npub: &str) -> String { .map(|pk| pk.to_hex()) .unwrap_or_else(|_| npub.to_string()) } + +/// Largest point size in `[12.0, 16.0]` at which the semibold news title fits on +/// one line within `avail` px, measured against the live font atlas and stepping +/// down by 0.5. Returns the 12pt floor when even that overflows (the caller pairs +/// it with `.truncate()`). This is the shrink-to-fit safety net that keeps a +/// title readable on a 390px screen; the hard char cap (`news_title_clamped`) is +/// the predictable ceiling. +fn fit_news_title_pt(ui: &egui::Ui, text: &str, avail: f32) -> f32 { + const CEIL: f32 = 16.0; + const FLOOR: f32 = 12.0; + let mut pt = CEIL; + while pt > FLOOR { + let w = ui + .painter() + .layout_no_wrap( + text.to_owned(), + FontId::new(pt, fonts::semibold()), + egui::Color32::WHITE, + ) + .size() + .x; + if w <= avail { + return pt; + } + pt -= 0.5; + } + FLOOR +} diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 0bf6e3ad..90747905 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -1810,12 +1810,15 @@ async fn handle_news(svc: &Arc, news_pk: PublicKey, event: Event) &event.content, ); let lang = news_lang_tag(&event); + let published_at = + first_tag_value(&event, "published_at").and_then(|s| s.trim().parse::().ok()); svc.store.save_news(NewsItem { d, created_at: event.created_at.as_secs() as i64, title, summary, lang, + published_at, }); } diff --git a/src/nostr/store.rs b/src/nostr/store.rs index 2dea4e96..661065ba 100644 --- a/src/nostr/store.rs +++ b/src/nostr/store.rs @@ -367,6 +367,7 @@ mod tests { title: format!("t{created_at}"), summary: String::new(), lang: None, + published_at: None, } } diff --git a/src/nostr/types.rs b/src/nostr/types.rs index 7c8ad694..22e24333 100644 --- a/src/nostr/types.rs +++ b/src/nostr/types.rs @@ -202,6 +202,12 @@ pub struct NewsItem { /// before this field existed still deserialize. #[serde(default)] pub lang: Option, + /// Optional NIP-23 `published_at` tag (unix seconds). When present the Home + /// panel dates the article by this rather than `created_at` (which tracks the + /// event's last edit). `#[serde(default)]` so posts cached before this field + /// existed still deserialize. + #[serde(default)] + pub published_at: Option, } /// Whether the plain "payment sent" receipt (frozen contract 4.3.1) is due at