Build 45: receipts, activity grouping, profiles + block
Rich transaction receipt screen (tap an activity row, or the new Receipt button
on a send's success screen): counterparty, time, note, amount, and a
Transaction-details card joining GRIM's local metadata with the nostr
npub/username — status (Complete vs Pending N/min-conf), To/From, network fee,
Mimblewimble, and the slate id. A local archive, like GRIM.
Group the Activity feed into Pending (unconfirmed) + per-day sections.
Contact profile screen (tap a peer or the receipt counterparty): who they are,
the history between you, a Pay shortcut, and Block — a nostr-level mute that
drops their incoming payments/requests (Contact.blocked, checked in ingest).
Refine success/denied copy ("to/from" by direction; "ask them to send you grin
instead"). Make the profile avatar display-only — no custom-picture upload.
This commit is contained in:
@@ -8,12 +8,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# Auto-build on every published release so macOS — which can't be built on the
|
||||
# Linux release box — is produced for each build without manual steps. The
|
||||
# Linux/Windows jobs are gated to manual dispatch only (those are built locally
|
||||
# and uploaded with the release), so a publish event builds just macOS.
|
||||
release:
|
||||
types: [published]
|
||||
# macOS is DEFERRED until Linux/Windows/Android are polished — so this is
|
||||
# manual-dispatch only for now (no auto-build on release publish). When macOS
|
||||
# is back on the table, re-add `release: { types: [published] }` here and the
|
||||
# macOS job will attach a universal build to each release automatically.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
|
||||
@@ -35,6 +35,104 @@ pub struct ActivityItem {
|
||||
pub npub: Option<String>,
|
||||
}
|
||||
|
||||
/// Full detail for the receipt / transaction-detail screen: GRIM tx data
|
||||
/// joined with the nostr counterparty + note. Mimblewimble keeps the chain
|
||||
/// private, but this is a LOCAL archive (like GRIM), so we surface whatever
|
||||
/// the wallet recorded plus the npub/username we exchanged with.
|
||||
pub struct ReceiptDetail {
|
||||
pub tx_id: u32,
|
||||
pub title: String,
|
||||
pub hue: usize,
|
||||
pub npub: Option<String>,
|
||||
pub amount: u64,
|
||||
pub incoming: bool,
|
||||
pub confirmed: bool,
|
||||
/// (current confirmations, required) when still pending and computable.
|
||||
pub confs: Option<(u64, u64)>,
|
||||
pub time: i64,
|
||||
pub note: Option<String>,
|
||||
/// Network fee in atomic units (sends only; unknown for receives).
|
||||
pub fee: Option<u64>,
|
||||
pub slate_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the receipt detail for a transaction id.
|
||||
pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option<ReceiptDetail> {
|
||||
let data = wallet.get_data()?;
|
||||
let txs = data.txs.as_ref()?;
|
||||
let tx = txs.iter().find(|t| t.data.id == tx_id)?;
|
||||
let incoming = matches!(
|
||||
tx.data.tx_type,
|
||||
TxLogEntryType::TxReceived | TxLogEntryType::ConfirmedCoinbase
|
||||
);
|
||||
let system = matches!(tx.data.tx_type, TxLogEntryType::ConfirmedCoinbase);
|
||||
let slate_id = tx.data.tx_slate_id.map(|u| u.to_string());
|
||||
let store = wallet.nostr_service().map(|s| s.store.clone());
|
||||
let store_ref = store.as_deref();
|
||||
let meta: Option<TxNostrMeta> = slate_id
|
||||
.as_ref()
|
||||
.and_then(|sid| store_ref.and_then(|s| s.tx_meta(sid)));
|
||||
let (title, hue) = if system {
|
||||
("Mining reward".to_string(), 5)
|
||||
} else if let Some(m) = &meta {
|
||||
store_ref
|
||||
.map(|s| contact_title(s, &m.npub))
|
||||
.unwrap_or_else(|| (short_npub(&m.npub), 0))
|
||||
} else {
|
||||
let label = if incoming { "Received" } else { "Sent" };
|
||||
(
|
||||
label.to_string(),
|
||||
(tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(),
|
||||
)
|
||||
};
|
||||
let note = meta.as_ref().and_then(|m| m.note.clone());
|
||||
let time = tx
|
||||
.data
|
||||
.confirmation_ts
|
||||
.or(Some(tx.data.creation_ts))
|
||||
.map(|t| t.timestamp())
|
||||
.unwrap_or(0);
|
||||
// A send's fee is the debit beyond the amount; a receive doesn't pay one.
|
||||
let fee = if incoming {
|
||||
None
|
||||
} else {
|
||||
Some(tx.data.amount_debited.saturating_sub(tx.amount))
|
||||
};
|
||||
let confs = if tx.data.confirmed {
|
||||
None
|
||||
} else {
|
||||
match tx.height {
|
||||
Some(h) if h > 0 && data.info.last_confirmed_height >= h => Some((
|
||||
data.info.last_confirmed_height - h + 1,
|
||||
data.info.minimum_confirmations,
|
||||
)),
|
||||
_ => Some((0, data.info.minimum_confirmations)),
|
||||
}
|
||||
};
|
||||
Some(ReceiptDetail {
|
||||
tx_id,
|
||||
title,
|
||||
hue,
|
||||
npub: meta.map(|m| m.npub),
|
||||
amount: tx.amount,
|
||||
incoming,
|
||||
confirmed: tx.data.confirmed,
|
||||
confs,
|
||||
time,
|
||||
note,
|
||||
fee,
|
||||
slate_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Activity entries exchanged with a single counterparty (for their profile).
|
||||
pub fn history_with(wallet: &Wallet, npub: &str) -> Vec<ActivityItem> {
|
||||
activity_items(wallet)
|
||||
.into_iter()
|
||||
.filter(|i| i.npub.as_deref() == Some(npub))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve the display title for a contact npub.
|
||||
pub fn contact_title(store: &NostrStore, npub: &str) -> (String, usize) {
|
||||
if let Some(contact) = store.contact(npub) {
|
||||
|
||||
+409
-12
@@ -23,7 +23,9 @@ pub mod widgets;
|
||||
use eframe::epaint::{CornerRadius, FontId, Stroke};
|
||||
use egui::{Align, Color32, Layout, Margin, RichText, ScrollArea, Sense, Vec2};
|
||||
|
||||
use crate::gui::icons::{ARROW_DOWN, CHECK, CLOCK, COPY, QR_CODE, USER_CIRCLE, WALLET};
|
||||
use crate::gui::icons::{
|
||||
ARROW_DOWN, ARROW_LEFT, CHECK, CLOCK, COPY, PROHIBIT, QR_CODE, USER_CIRCLE, WALLET,
|
||||
};
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::theme::{self, fonts};
|
||||
use crate::gui::views::{Content, TextEdit, View};
|
||||
@@ -50,6 +52,10 @@ pub enum Tab {
|
||||
pub struct GoblinWalletView {
|
||||
tab: Tab,
|
||||
send: Option<SendFlow>,
|
||||
/// Open transaction receipt by tx id (full-surface overlay).
|
||||
receipt: Option<u32>,
|
||||
/// Open contact profile by npub hex (full-surface overlay).
|
||||
profile: Option<String>,
|
||||
/// Request ids already approved this session (double-tap guard).
|
||||
approving: std::collections::HashSet<String>,
|
||||
/// Identifier of the wallet this view is bound to (reset on change).
|
||||
@@ -100,6 +106,8 @@ impl Default for GoblinWalletView {
|
||||
Self {
|
||||
tab: Tab::Home,
|
||||
send: None,
|
||||
receipt: None,
|
||||
profile: None,
|
||||
approving: std::collections::HashSet::new(),
|
||||
wallet_id: None,
|
||||
claim: None,
|
||||
@@ -225,6 +233,14 @@ impl GoblinWalletView {
|
||||
|
||||
/// Handle a back navigation; returns true if not consumed.
|
||||
pub fn on_back(&mut self) -> bool {
|
||||
if self.receipt.is_some() {
|
||||
self.receipt = None;
|
||||
return false;
|
||||
}
|
||||
if self.profile.is_some() {
|
||||
self.profile = None;
|
||||
return false;
|
||||
}
|
||||
if self.send.is_some() {
|
||||
self.send = None;
|
||||
return false;
|
||||
@@ -251,6 +267,8 @@ impl GoblinWalletView {
|
||||
self.wallet_id = Some(id);
|
||||
self.tab = Tab::Home;
|
||||
self.send = None;
|
||||
self.receipt = None;
|
||||
self.profile = None;
|
||||
self.claim = None;
|
||||
self.rotate = None;
|
||||
self.import_nsec = None;
|
||||
@@ -264,7 +282,27 @@ impl GoblinWalletView {
|
||||
if let Some(send) = &mut self.send {
|
||||
let done = send.ui(ui, wallet, cb, &mut self.avatars);
|
||||
if done {
|
||||
let receipt_npub = send.receipt_npub.clone();
|
||||
self.send = None;
|
||||
// "Receipt" on the success screen opens the latest tx with them.
|
||||
if let Some(npub) = receipt_npub {
|
||||
if let Some(item) = data::history_with(wallet, &npub).into_iter().next() {
|
||||
self.receipt = Some(item.tx_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Receipt + contact profile are full-surface overlays as well.
|
||||
if let Some(tx_id) = self.receipt {
|
||||
if self.receipt_ui(ui, wallet, tx_id) {
|
||||
self.receipt = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(npub) = self.profile.clone() {
|
||||
if self.profile_ui(ui, wallet, cb, &npub) {
|
||||
self.profile = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -793,9 +831,7 @@ impl GoblinWalletView {
|
||||
let short: String = name.chars().take(6).collect();
|
||||
ui.label(RichText::new(short).font(FontId::new(12.0, fonts::medium())));
|
||||
if resp.clicked() {
|
||||
let mut f = SendFlow::default();
|
||||
f.prefill_contact(name.clone(), npub.clone());
|
||||
self.send = Some(f);
|
||||
self.profile = Some(npub.clone());
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
@@ -963,6 +999,347 @@ impl GoblinWalletView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Round back button + title for full-surface overlays. Returns true on tap.
|
||||
fn overlay_back_header(ui: &mut egui::Ui, title: &str) -> bool {
|
||||
let t = theme::tokens();
|
||||
let mut back = false;
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click());
|
||||
ui.painter().circle_filled(rect.center(), 18.0, t.surface2);
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
ARROW_LEFT,
|
||||
FontId::new(16.0, fonts::regular()),
|
||||
t.text,
|
||||
);
|
||||
back = resp
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.clicked();
|
||||
ui.add_space(12.0);
|
||||
ui.label(
|
||||
RichText::new(title)
|
||||
.font(FontId::new(18.0, fonts::bold()))
|
||||
.color(t.text),
|
||||
);
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
back
|
||||
}
|
||||
|
||||
/// Full-surface transaction receipt: GRIM metadata joined with the nostr
|
||||
/// counterparty + note. Tapping the counterparty opens their profile.
|
||||
fn receipt_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, tx_id: u32) -> bool {
|
||||
let t = theme::tokens();
|
||||
let d = data::receipt_detail(wallet, tx_id);
|
||||
let tex = d
|
||||
.as_ref()
|
||||
.and_then(|d| self.handle_tex(ui.ctx(), wallet, &d.title));
|
||||
let mut close = false;
|
||||
let mut open_profile: Option<String> = None;
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame {
|
||||
fill: t.bg,
|
||||
inner_margin: Margin {
|
||||
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
|
||||
right: (View::get_right_inset() + 20.0) as i8,
|
||||
top: (View::get_top_inset() + 12.0) as i8,
|
||||
bottom: (View::get_bottom_inset() + 12.0) as i8,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.show_inside(ui, |ui| {
|
||||
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
|
||||
if Self::overlay_back_header(ui, "Receipt") {
|
||||
close = true;
|
||||
}
|
||||
let Some(d) = d else {
|
||||
ui.add_space(40.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Transaction not found")
|
||||
.font(FontId::new(15.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
return;
|
||||
};
|
||||
ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
let resp = w::avatar_any(ui, &d.title, 64.0, d.hue, tex.as_ref());
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
RichText::new(&d.title)
|
||||
.font(FontId::new(22.0, fonts::bold()))
|
||||
.color(t.text),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
RichText::new(View::format_time(d.time))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
if let Some(note) = &d.note {
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
RichText::new(format!("For {}", note))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
w::amount_text_centered(ui, &w::amount_str(d.amount), 56.0);
|
||||
if resp.clicked() {
|
||||
if let Some(npub) = &d.npub {
|
||||
open_profile = Some(npub.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(20.0);
|
||||
w::kicker(ui, "Transaction details");
|
||||
ui.add_space(10.0);
|
||||
w::card(ui, |ui| {
|
||||
let (status, sub) = if d.confirmed {
|
||||
(
|
||||
"Complete",
|
||||
if d.incoming {
|
||||
"Payment received".to_string()
|
||||
} else {
|
||||
"Payment sent successfully".to_string()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Pending",
|
||||
match d.confs {
|
||||
Some((c, r)) => format!("{}/{} confirmations", c, r),
|
||||
None => "Waiting to confirm".to_string(),
|
||||
},
|
||||
)
|
||||
};
|
||||
w::info_row(ui, status, &sub);
|
||||
let (to, from) = if d.incoming {
|
||||
("You".to_string(), d.title.clone())
|
||||
} else {
|
||||
(d.title.clone(), "You".to_string())
|
||||
};
|
||||
w::info_row(ui, "To", &to);
|
||||
w::info_row(ui, "From", &from);
|
||||
if let Some(npub) = &d.npub {
|
||||
w::info_row(ui, "nostr", &data::short_npub(npub));
|
||||
}
|
||||
let fee = match d.fee {
|
||||
Some(0) => "None".to_string(),
|
||||
Some(f) => format!("{}{}", w::amount_str(f), w::TSU),
|
||||
None => "—".to_string(),
|
||||
};
|
||||
w::info_row(ui, "Network fee", &fee);
|
||||
w::info_row(ui, "Privacy", "Mimblewimble");
|
||||
if let Some(sid) = &d.slate_id {
|
||||
let short = if sid.len() > 13 {
|
||||
format!("{}…{}", &sid[..8], &sid[sid.len() - 4..])
|
||||
} else {
|
||||
sid.clone()
|
||||
};
|
||||
w::info_row(ui, "Transaction", &short);
|
||||
}
|
||||
});
|
||||
ui.add_space(20.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(npub) = open_profile {
|
||||
self.profile = Some(npub);
|
||||
close = true;
|
||||
}
|
||||
close
|
||||
}
|
||||
|
||||
/// Full-surface contact profile: who they are, history between us, and a
|
||||
/// block toggle (a nostr-level mute).
|
||||
fn profile_ui(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
wallet: &Wallet,
|
||||
_cb: &dyn PlatformCallbacks,
|
||||
npub: &str,
|
||||
) -> bool {
|
||||
let t = theme::tokens();
|
||||
let (name, hue) = wallet
|
||||
.nostr_service()
|
||||
.map(|s| data::contact_title(&s.store, npub))
|
||||
.unwrap_or_else(|| (data::short_npub(npub), 0));
|
||||
let contact = wallet.nostr_service().and_then(|s| s.store.contact(npub));
|
||||
let blocked = contact.as_ref().map(|c| c.blocked).unwrap_or(false);
|
||||
let nip05 = contact.as_ref().and_then(|c| c.nip05.clone());
|
||||
let history = data::history_with(wallet, npub);
|
||||
let tex = self.handle_tex(ui.ctx(), wallet, &name);
|
||||
let htexs: Vec<Option<egui::TextureHandle>> = history
|
||||
.iter()
|
||||
.map(|i| self.handle_tex(ui.ctx(), wallet, &i.title))
|
||||
.collect();
|
||||
let mut close = false;
|
||||
let mut do_pay = false;
|
||||
let mut do_block = false;
|
||||
let mut open_receipt: Option<u32> = None;
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame {
|
||||
fill: t.bg,
|
||||
inner_margin: Margin {
|
||||
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
|
||||
right: (View::get_right_inset() + 20.0) as i8,
|
||||
top: (View::get_top_inset() + 12.0) as i8,
|
||||
bottom: (View::get_bottom_inset() + 12.0) as i8,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.show_inside(ui, |ui| {
|
||||
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
|
||||
if Self::overlay_back_header(ui, "Profile") {
|
||||
close = true;
|
||||
}
|
||||
ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
w::avatar_any(ui, &name, 72.0, hue, tex.as_ref());
|
||||
ui.add_space(12.0);
|
||||
ui.label(
|
||||
RichText::new(&name)
|
||||
.font(FontId::new(22.0, fonts::bold()))
|
||||
.color(t.text),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
let sub = nip05
|
||||
.clone()
|
||||
.map(|n| format!("✓ {}", n))
|
||||
.unwrap_or_else(|| data::short_npub(npub));
|
||||
ui.label(
|
||||
RichText::new(sub)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
ui.add_space(18.0);
|
||||
if !blocked && w::big_action(ui, "Pay", false).clicked() {
|
||||
do_pay = true;
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
w::kicker(ui, "Activity");
|
||||
ui.add_space(10.0);
|
||||
if history.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("No activity with them yet.")
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
} else {
|
||||
for (item, htex) in history.iter().zip(htexs.iter()) {
|
||||
let sign = if item.incoming { "+ " } else { "− " };
|
||||
let amount =
|
||||
format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
|
||||
let subtitle = match (&item.note, item.confirmed) {
|
||||
(Some(n), true) => {
|
||||
format!("{} · {}", n, View::format_time(item.time))
|
||||
}
|
||||
(Some(n), false) => format!("{} · pending", n),
|
||||
(None, true) => View::format_time(item.time),
|
||||
(None, false) => "pending".to_string(),
|
||||
};
|
||||
if w::activity_row(
|
||||
ui,
|
||||
&item.title,
|
||||
&subtitle,
|
||||
item.hue,
|
||||
&amount,
|
||||
item.incoming,
|
||||
item.system,
|
||||
htex.as_ref(),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
open_receipt = Some(item.tx_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(24.0);
|
||||
let label = if blocked {
|
||||
"Unblock".to_string()
|
||||
} else {
|
||||
format!("{} Block", PROHIBIT)
|
||||
};
|
||||
if w::big_action_on_card_ink(ui, &label, t.neg).clicked() {
|
||||
do_block = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(if blocked {
|
||||
"Blocked — their payments and requests are dropped."
|
||||
} else {
|
||||
"Blocking drops their incoming payments and requests."
|
||||
})
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
});
|
||||
ui.add_space(20.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(id) = open_receipt {
|
||||
self.receipt = Some(id);
|
||||
close = true;
|
||||
}
|
||||
if do_pay {
|
||||
let mut f = SendFlow::default();
|
||||
f.prefill_contact(name.clone(), npub.to_string());
|
||||
self.send = Some(f);
|
||||
close = true;
|
||||
}
|
||||
if do_block {
|
||||
if let Some(s) = wallet.nostr_service() {
|
||||
let mut c = s.store.contact(npub).unwrap_or(crate::nostr::Contact {
|
||||
ver: 1,
|
||||
npub: npub.to_string(),
|
||||
petname: None,
|
||||
nip05: nip05.clone(),
|
||||
nip05_verified_at: None,
|
||||
relays: vec![],
|
||||
hue: hue as u8,
|
||||
unknown: true,
|
||||
added_at: crate::nostr::unix_time(),
|
||||
last_paid_at: None,
|
||||
blocked: false,
|
||||
});
|
||||
c.blocked = !c.blocked;
|
||||
s.store.save_contact(&c);
|
||||
}
|
||||
}
|
||||
close
|
||||
}
|
||||
|
||||
/// Friendly day-grouping label for the activity feed.
|
||||
fn day_label(ts: i64) -> String {
|
||||
use chrono::{TimeZone, Utc};
|
||||
let Some(dt) = Utc.timestamp_opt(ts, 0).single() else {
|
||||
return "Earlier".to_string();
|
||||
};
|
||||
let today = Utc::now().date_naive();
|
||||
let day = dt.date_naive();
|
||||
if day == today {
|
||||
"Today".to_string()
|
||||
} else if (today - day).num_days() == 1 {
|
||||
"Yesterday".to_string()
|
||||
} else {
|
||||
dt.format("%b %-d, %Y").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn activity_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
@@ -995,7 +1372,24 @@ impl GoblinWalletView {
|
||||
if items.is_empty() {
|
||||
empty_state(ui, "No activity yet", "Your payments will appear here.");
|
||||
} else {
|
||||
for item in &items {
|
||||
// Unconfirmed (< min confirmations) pinned on top as Pending.
|
||||
let pending: Vec<&_> =
|
||||
items.iter().filter(|i| !i.confirmed && !i.system).collect();
|
||||
if !pending.is_empty() {
|
||||
w::section_header(ui, "Pending");
|
||||
for item in pending {
|
||||
self.activity_item_ui(ui, item, wallet, cb);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
// Confirmed, grouped by day (newest first).
|
||||
let mut last: Option<String> = None;
|
||||
for item in items.iter().filter(|i| i.confirmed || i.system) {
|
||||
let label = Self::day_label(item.time);
|
||||
if last.as_deref() != Some(label.as_str()) {
|
||||
w::section_header(ui, &label);
|
||||
last = Some(label);
|
||||
}
|
||||
self.activity_item_ui(ui, item, wallet, cb);
|
||||
}
|
||||
}
|
||||
@@ -1019,7 +1413,7 @@ impl GoblinWalletView {
|
||||
(None, false) => "pending".to_string(),
|
||||
};
|
||||
let tex = self.handle_tex(ui.ctx(), wallet, &item.title);
|
||||
w::activity_row(
|
||||
if w::activity_row(
|
||||
ui,
|
||||
&item.title,
|
||||
&subtitle,
|
||||
@@ -1028,7 +1422,11 @@ impl GoblinWalletView {
|
||||
item.incoming,
|
||||
item.system,
|
||||
tex.as_ref(),
|
||||
);
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.receipt = Some(item.tx_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn request_row_ui(
|
||||
@@ -1324,11 +1722,10 @@ impl GoblinWalletView {
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
let av = w::avatar_any(ui, &handle, 56.0, hue, own_tex.as_ref())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if av.clicked() && !avatar_busy {
|
||||
pick_picture = true;
|
||||
}
|
||||
// Avatar is display-only for now: tapping does nothing (no custom
|
||||
// picture upload). Letter/identicon pucks only.
|
||||
w::avatar_any(ui, &handle, 56.0, hue, own_tex.as_ref());
|
||||
let _ = (avatar_busy, &mut pick_picture);
|
||||
ui.add_space(14.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
|
||||
@@ -122,6 +122,9 @@ pub struct SendFlow {
|
||||
/// Request mode: issue an Invoice1 to the recipient (ask them to pay) rather
|
||||
/// than sending them money. Reuses the recipient picker; no balance guard.
|
||||
request: bool,
|
||||
/// Set when the success screen's "Receipt" button is tapped: the host view
|
||||
/// opens the receipt for the latest tx with this npub after the flow closes.
|
||||
pub receipt_npub: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SendFlow {
|
||||
@@ -143,6 +146,7 @@ impl Default for SendFlow {
|
||||
scan: None,
|
||||
start_scan: false,
|
||||
request: false,
|
||||
receipt_npub: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1081,7 +1085,15 @@ impl SendFlow {
|
||||
match phase {
|
||||
crate::nostr::send_phase::SENT => self.stage = Stage::Success,
|
||||
crate::nostr::send_phase::REQUEST_BLOCKED => {
|
||||
self.error = Some("They're not accepting requests right now.".to_string());
|
||||
let who = self
|
||||
.recipient
|
||||
.as_ref()
|
||||
.map(|r| r.name.clone())
|
||||
.unwrap_or_else(|| "They".to_string());
|
||||
self.error = Some(format!(
|
||||
"{} isn't accepting requests. Ask them to send you grin instead.",
|
||||
who
|
||||
));
|
||||
self.stage = Stage::Failed;
|
||||
}
|
||||
crate::nostr::send_phase::FAILED => self.stage = Stage::Failed,
|
||||
@@ -1114,7 +1126,8 @@ impl SendFlow {
|
||||
ui.label(
|
||||
RichText::new(self.error.clone().unwrap_or_else(|| {
|
||||
if self.request {
|
||||
"The request wasn't delivered — try again.".to_string()
|
||||
"We couldn't deliver the request. Ask them to send you grin instead."
|
||||
.to_string()
|
||||
} else {
|
||||
"The payment wasn't delivered. Your grin is safe — try again.".to_string()
|
||||
}
|
||||
@@ -1175,13 +1188,19 @@ impl SendFlow {
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(format!("to {} · just now", recipient.name))
|
||||
.font(FontId::new(15.0, fonts::regular()))
|
||||
.color(t.accent_ink.gamma_multiply(0.7)),
|
||||
RichText::new(format!(
|
||||
"{} {} · just now",
|
||||
if self.request { "from" } else { "to" },
|
||||
recipient.name
|
||||
))
|
||||
.font(FontId::new(15.0, fonts::regular()))
|
||||
.color(t.accent_ink.gamma_multiply(0.7)),
|
||||
);
|
||||
});
|
||||
|
||||
let mut done = false;
|
||||
let is_request = self.request;
|
||||
let mut want_receipt = false;
|
||||
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
|
||||
ui.add_space(20.0);
|
||||
let (rect, resp) =
|
||||
@@ -1203,7 +1222,34 @@ impl SendFlow {
|
||||
if resp.clicked() {
|
||||
done = true;
|
||||
}
|
||||
// Receipt (secondary; sends only) — sits above Done in bottom-up.
|
||||
if !is_request {
|
||||
ui.add_space(10.0);
|
||||
let (r2, resp2) =
|
||||
ui.allocate_exact_size(Vec2::new(ui.available_width(), 56.0), Sense::click());
|
||||
ui.painter().rect(
|
||||
r2,
|
||||
eframe::epaint::CornerRadius::same(14),
|
||||
egui::Color32::TRANSPARENT,
|
||||
eframe::epaint::Stroke::new(1.5, t.accent_ink),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.painter().text(
|
||||
r2.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"Receipt",
|
||||
FontId::new(17.0, fonts::semibold()),
|
||||
t.accent_ink,
|
||||
);
|
||||
if resp2.clicked() {
|
||||
want_receipt = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
if want_receipt {
|
||||
self.receipt_npub = Some(recipient.npub.clone());
|
||||
done = true;
|
||||
}
|
||||
done
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,6 +419,7 @@ impl NostrService {
|
||||
unknown: true,
|
||||
added_at: unix_time(),
|
||||
last_paid_at: None,
|
||||
blocked: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -669,6 +670,17 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
|
||||
return;
|
||||
}
|
||||
let sender_hex = sender.to_hex();
|
||||
// Blocked sender: drop silently, a nostr-level mute. Mark processed so we
|
||||
// don't reconsider it on every catch-up.
|
||||
if svc
|
||||
.store
|
||||
.contact(&sender_hex)
|
||||
.map(|c| c.blocked)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
svc.store.mark_processed(&wrap_id);
|
||||
return;
|
||||
}
|
||||
let is_contact = svc
|
||||
.store
|
||||
.contact(&sender_hex)
|
||||
|
||||
@@ -92,6 +92,10 @@ pub struct Contact {
|
||||
pub unknown: bool,
|
||||
pub added_at: i64,
|
||||
pub last_paid_at: Option<i64>,
|
||||
/// Blocked at the nostr level: their incoming messages are dropped on
|
||||
/// ingest, as if muted on nostr (which is what this is).
|
||||
#[serde(default)]
|
||||
pub blocked: bool,
|
||||
}
|
||||
|
||||
/// Status of an incoming payment request (Invoice1).
|
||||
|
||||
Reference in New Issue
Block a user