diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5cfbffc..ab436297 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index 86b889a8..b025bdfc 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -35,6 +35,104 @@ pub struct ActivityItem { pub npub: Option, } +/// 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, + 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, + /// Network fee in atomic units (sends only; unknown for receives). + pub fee: Option, + pub slate_id: Option, +} + +/// Build the receipt detail for a transaction id. +pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option { + 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 = 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 { + 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) { diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index b695fa7d..43cb4fa9 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -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, + /// Open transaction receipt by tx id (full-surface overlay). + receipt: Option, + /// Open contact profile by npub hex (full-surface overlay). + profile: Option, /// Request ids already approved this session (double-tap guard). approving: std::collections::HashSet, /// 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 = 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> = 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 = 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 = 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( diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 799176d2..33439c0e 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -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, } 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 } } diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 1396fbaa..78540b05 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -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, 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) diff --git a/src/nostr/types.rs b/src/nostr/types.rs index 90697523..22c25f37 100644 --- a/src/nostr/types.rs +++ b/src/nostr/types.rs @@ -92,6 +92,10 @@ pub struct Contact { pub unknown: bool, pub added_at: i64, pub last_paid_at: Option, + /// 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).