1
0
forked from GRIN/grim

goblin: refine the activity row and drop list seconds

Rebuild the activity row so the message pins left and truncates while the
signed amount pins right with the date/time right-aligned directly beneath
it. List timestamps lose the seconds (HH:MM); the tap-in detail view keeps
the full second-precision time. A row with no timestamp (a canceled tx)
draws no right-side time slot at all and folds its status word into the
left message instead. Shared note/time derivation lives in one helper.
This commit is contained in:
2ro
2026-07-05 13:10:43 -04:00
parent 1141f97b22
commit ce024443ac
2 changed files with 99 additions and 86 deletions
+39 -30
View File
@@ -1775,24 +1775,12 @@ impl GoblinWalletView {
};
let amount =
format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
let status_word = if item.canceled {
t!("goblin.activity.canceled").to_string()
} else {
t!("goblin.activity.pending").to_string()
};
// Note truncates; the tail (date/time when
// confirmed, else the status word) stays in full.
let note = item.note.as_deref().unwrap_or("");
let tail = if item.confirmed {
View::format_time(item.time)
} else {
status_word.clone()
};
let (note, time) = Self::activity_note_time(item);
if w::activity_row(
ui,
&item.title,
note,
&tail,
&note,
&time,
item.npub.as_deref().unwrap_or(""),
&amount,
item.incoming,
@@ -1864,6 +1852,39 @@ impl GoblinWalletView {
close
}
/// List-row timestamp: date + HH:MM, no seconds. The tap-in detail view keeps
/// the full timestamp to the second (see [`View::format_time`]).
fn list_time(ts: i64) -> String {
let utc_offset = chrono::Local::now().offset().local_minus_utc();
chrono::DateTime::from_timestamp(ts + utc_offset as i64, 0)
.map(|t| t.format("%d/%m/%Y %H:%M").to_string())
.unwrap_or_default()
}
/// The (left message, right timestamp) an [`ActivityItem`] shows in a row. The
/// timestamp (no seconds) is only set for a confirmed tx; otherwise the status
/// word (canceled/pending) folds into the message so a row with no time still
/// reads its state without an empty right-side time slot.
fn activity_note_time(item: &ActivityItem) -> (String, String) {
let status_word = if item.canceled {
t!("goblin.activity.canceled").to_string()
} else {
t!("goblin.activity.pending").to_string()
};
let time = if item.confirmed {
Self::list_time(item.time)
} else {
String::new()
};
let note = match (item.note.as_deref(), item.confirmed) {
(Some(n), false) => format!("{n} · {status_word}"),
(None, false) => status_word,
(Some(n), true) => n.to_string(),
(None, true) => String::new(),
};
(note, time)
}
/// Friendly day-grouping label for the activity feed.
fn day_label(ts: i64) -> String {
use chrono::{TimeZone, Utc};
@@ -1982,25 +2003,13 @@ impl GoblinWalletView {
" "
};
let amount = format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
let status_word = if item.canceled {
t!("goblin.activity.canceled").to_string()
} else {
t!("goblin.activity.pending").to_string()
};
// Note truncates; the tail (date/time when confirmed, else the status
// word) is pinned right and kept in full.
let note = item.note.as_deref().unwrap_or("");
let tail = if item.confirmed {
View::format_time(item.time)
} else {
status_word.clone()
};
let (note, time) = Self::activity_note_time(item);
let tex = self.handle_tex(ui.ctx(), wallet, &item.title);
if w::activity_row(
ui,
&item.title,
note,
&tail,
&note,
&time,
item.npub.as_deref().unwrap_or(""),
&amount,
item.incoming,
+60 -56
View File
@@ -615,13 +615,15 @@ pub fn balance_hero(
}
}
/// An activity row: avatar, title, subtitle, signed amount.
/// An activity row: avatar, a left title/message column that truncates, and a
/// right column with the signed amount over the date/time. `time` is the
/// right-side timestamp (empty draws no time line — e.g. a canceled tx).
/// Returns the row click response.
pub fn activity_row(
ui: &mut Ui,
title: &str,
note: &str,
tail: &str,
time: &str,
id: &str,
amount: &str,
incoming: bool,
@@ -662,29 +664,56 @@ pub fn activity_row(
avatar_any(ui, title, id, 40.0, tex);
}
ui.add_space(12.0);
// Reserve the amount as its own right-hand column FIRST. Placing it
// before the text means the title/subtitle column is bounded to the
// remaining width and truncates cleanly, instead of stretching under
// the amount and colliding with it. Centered against the whole stack,
// the amount lands between the title and subtitle lines.
// Right column FIRST so the left title/message column is bounded to the
// remaining width and truncates cleanly. The amount sits on top with the
// date/time right-aligned directly beneath it; a row with no timestamp
// (a canceled tx) draws no time line at all.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if !amount.is_empty() {
// A canceled tx delivered no funds: mute the amount so it never
// reads as a completed green credit (or a real debit).
ui.label(
RichText::new(amount)
.font(FontId::new(15.0, fonts::mono_semibold()))
.color(if canceled {
t.text_dim
} else if incoming {
t.pos
} else {
t.text
}),
let amt_ink = if canceled {
t.text_dim
} else if incoming {
t.pos
} else {
t.text
};
let amt_font = FontId::new(15.0, fonts::mono_semibold());
let time_font = FontId::new(13.0, fonts::regular());
let amt_g = (!amount.is_empty()).then(|| {
ui.painter()
.layout_no_wrap(amount.to_string(), amt_font, amt_ink)
});
let time_g = (!time.is_empty()).then(|| {
ui.painter()
.layout_no_wrap(time.to_string(), time_font, t.text_dim)
});
let col_w = amt_g
.as_ref()
.map(|g| g.size().x)
.unwrap_or(0.0)
.max(time_g.as_ref().map(|g| g.size().x).unwrap_or(0.0));
if col_w > 0.0 {
let amt_h = amt_g.as_ref().map(|g| g.size().y).unwrap_or(0.0);
let time_h = time_g.as_ref().map(|g| g.size().y).unwrap_or(0.0);
let col_h = amt_h + if time_h > 0.0 { 2.0 + time_h } else { 0.0 };
ui.allocate_ui_with_layout(
Vec2::new(col_w, col_h),
Layout::top_down(Align::Max),
|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
if let Some(g) = amt_g {
let (r, _) = ui.allocate_exact_size(g.size(), Sense::hover());
ui.painter().galley(r.min, g, amt_ink);
}
if let Some(g) = time_g {
let (r, _) = ui.allocate_exact_size(g.size(), Sense::hover());
ui.painter().galley(r.min, g, t.text_dim);
}
},
);
ui.add_space(10.0);
}
// Remaining width to the left holds the title + subtitle stack.
// Remaining width to the left: the counterparty/title on top, the
// message pinned left and truncated with an ellipsis beneath it.
ui.vertical(|ui| {
ui.add_space(2.0);
ui.add(
@@ -695,7 +724,16 @@ pub fn activity_row(
)
.truncate(),
);
activity_subtitle(ui, note, tail, t.text_dim);
if !note.is_empty() {
ui.add(
egui::Label::new(
RichText::new(note)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
)
.truncate(),
);
}
});
});
});
@@ -706,40 +744,6 @@ pub fn activity_row(
resp
}
/// Single-line subtitle for an activity row.
///
/// The `tail` (date/time, or a short status word) is pinned to the right and
/// never clipped; the `note` takes whatever width is left and gets the
/// ellipsis when it runs out of room. When only one part is present it is
/// left-aligned under the title, truncating on its own (e.g. a long npub in
/// the contact picker).
fn activity_subtitle(ui: &mut Ui, note: &str, tail: &str, color: Color32) {
let dim = |s: &str| {
RichText::new(s.to_string())
.font(FontId::new(13.0, fonts::regular()))
.color(color)
};
match (note.is_empty(), tail.is_empty()) {
(true, true) => {}
(false, true) => {
ui.add(egui::Label::new(dim(note)).truncate());
}
(true, false) => {
ui.add(egui::Label::new(dim(tail)).truncate());
}
(false, false) => {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
// Rightmost, full: the date/time (with seconds) is never clipped.
ui.add(egui::Label::new(dim(tail)));
ui.add(egui::Label::new(dim("·")));
// Leftmost, fills the gap: the note truncates with an ellipsis.
ui.add(egui::Label::new(dim(note)).truncate());
});
}
}
}
/// Section header used above grouped lists.
pub fn section_header(ui: &mut Ui, text: &str) {
ui.add_space(8.0);