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:
@@ -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![
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -367,6 +367,7 @@ mod tests {
|
||||
title: format!("t{created_at}"),
|
||||
summary: String::new(),
|
||||
lang: None,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user