diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 4714c226..e31def06 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -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, + ¬e, + &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, + ¬e, + &time, item.npub.as_deref().unwrap_or(""), &amount, item.incoming, diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 20ad50ba..74f8099f 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -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);