1
0
forked from GRIN/grim

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.
This commit is contained in:
2ro
2026-07-05 06:29:21 -04:00
parent e036a9692a
commit 1141f97b22
5 changed files with 120 additions and 2 deletions
+65
View File
@@ -406,6 +406,7 @@ fn news_pool(wallet: &Wallet) -> Vec<NewsItem> {
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<NewsItem> {
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<char> = title.chars().collect();
if chars.len() > NEWS_TITLE_MAX_CHARS {
let keep = NEWS_TITLE_MAX_CHARS.saturating_sub(1);
format!("{}", chars[..keep].iter().collect::<String>())
} 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![
+45 -2
View File
@@ -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
}
+3
View File
@@ -1810,12 +1810,15 @@ async fn handle_news(svc: &Arc<NostrService>, 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::<i64>().ok());
svc.store.save_news(NewsItem {
d,
created_at: event.created_at.as_secs() as i64,
title,
summary,
lang,
published_at,
});
}
+1
View File
@@ -367,6 +367,7 @@ mod tests {
title: format!("t{created_at}"),
summary: String::new(),
lang: None,
published_at: None,
}
}
+6
View File
@@ -202,6 +202,12 @@ pub struct NewsItem {
/// before this field existed still deserialize.
#[serde(default)]
pub lang: Option<String>,
/// 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<i64>,
}
/// Whether the plain "payment sent" receipt (frozen contract 4.3.1) is due at