diff --git a/src/gui/platform/desktop/mod.rs b/src/gui/platform/desktop/mod.rs index 3f8632da..74380eaf 100644 --- a/src/gui/platform/desktop/mod.rs +++ b/src/gui/platform/desktop/mod.rs @@ -292,6 +292,15 @@ impl PlatformCallbacks for Desktop { None } + fn pick_image_file(&self) -> Option { + let file = FileDialog::new() + .set_title(t!("choose_file")) + .add_filter("Images", &["png", "jpg", "jpeg", "webp"]) + .set_directory(dirs::home_dir().unwrap()) + .pick_file(); + file.and_then(|f| f.to_str().map(|s| s.to_string())) + } + fn pick_folder(&self) -> Option { let file = FileDialog::new() .set_title(t!("choose_folder")) diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs index 1cbd520e..8542ec25 100644 --- a/src/gui/platform/mod.rs +++ b/src/gui/platform/mod.rs @@ -33,6 +33,11 @@ pub trait PlatformCallbacks { fn switch_camera(&self); fn share_data(&self, name: String, data: Vec) -> Result<(), std::io::Error>; fn pick_file(&self) -> Option; + /// Native picker filtered to picture files; defaults to the plain picker + /// on platforms without filter support (magic-byte sniffing protects). + fn pick_image_file(&self) -> Option { + self.pick_file() + } fn pick_folder(&self) -> Option; fn picked_file(&self) -> Option; fn request_user_attention(&self); diff --git a/src/gui/theme.rs b/src/gui/theme.rs index f4c99c00..1bf44c0d 100644 --- a/src/gui/theme.rs +++ b/src/gui/theme.rs @@ -69,20 +69,46 @@ pub struct ThemeTokens { pub chip: Color32, pub hover: Color32, /// Avatar background palette (initial ink picked by luminance). - pub avatar_palette: [Color32; 7], + pub avatar_pairs: [(Color32, Color32); 8], /// Whether egui widgets should use the dark base style. pub dark_base: bool, } -/// Avatar palette shared by light/dark themes. -const AVATARS: [Color32; 7] = [ - Color32::from_rgb(0xFF, 0xD6, 0x0A), // accent - Color32::from_rgb(0xFF, 0x8E, 0x3C), - Color32::from_rgb(0x5B, 0xD2, 0x7A), - Color32::from_rgb(0x7B, 0xA7, 0xFF), - Color32::from_rgb(0xE1, 0x74, 0xD0), - Color32::from_rgb(0xFF, 0xB8, 0x00), - Color32::from_rgb(0xA0, 0xE6, 0x6E), +/// Avatar (background, ink) pairs shared by all themes — bright pastels +/// carry dark ink, saturated darks carry light ink. +const AVATAR_PAIRS: [(Color32, Color32); 8] = [ + ( + Color32::from_rgb(0xFF, 0xD6, 0x0A), + Color32::from_rgb(0x0E, 0x0E, 0x0C), + ), // accent yellow / ink + ( + Color32::from_rgb(0xFF, 0x8E, 0x3C), + Color32::from_rgb(0x26, 0x10, 0x02), + ), // orange / deep brown + ( + Color32::from_rgb(0x5B, 0xD2, 0x7A), + Color32::from_rgb(0x0E, 0x0E, 0x0C), + ), // light green / black + ( + Color32::from_rgb(0x7B, 0xA7, 0xFF), + Color32::from_rgb(0x0B, 0x14, 0x33), + ), // periwinkle / navy ink + ( + Color32::from_rgb(0x6B, 0x4F, 0xC8), + Color32::from_rgb(0xF4, 0xF0, 0xFF), + ), // purple / light text + ( + Color32::from_rgb(0xE1, 0x74, 0xD0), + Color32::from_rgb(0x32, 0x07, 0x2B), + ), // pink / dark plum + ( + Color32::from_rgb(0x1F, 0x7A, 0x5C), + Color32::from_rgb(0xE7, 0xFF, 0xF4), + ), // deep teal / light mint + ( + Color32::from_rgb(0xA0, 0xE6, 0x6E), + Color32::from_rgb(0x14, 0x22, 0x0A), + ), // lime / dark moss ]; pub const LIGHT: ThemeTokens = ThemeTokens { @@ -104,7 +130,7 @@ pub const LIGHT: ThemeTokens = ThemeTokens { neg: Color32::from_rgb(0xB0, 0x48, 0x1E), chip: Color32::from_rgb(0xF2, 0xF1, 0xEC), hover: Color32::from_rgb(0xE9, 0xE7, 0xE0), - avatar_palette: AVATARS, + avatar_pairs: AVATAR_PAIRS, dark_base: false, }; @@ -127,7 +153,7 @@ pub const DARK: ThemeTokens = ThemeTokens { neg: Color32::from_rgb(0xFF, 0x8B, 0x5E), chip: Color32::from_rgb(0x24, 0x24, 0x20), hover: Color32::from_rgb(0x2E, 0x2E, 0x29), - avatar_palette: AVATARS, + avatar_pairs: AVATAR_PAIRS, dark_base: true, }; @@ -150,7 +176,7 @@ pub const YELLOW: ThemeTokens = ThemeTokens { neg: Color32::from_rgb(0x9E, 0x2E, 0x0E), chip: Color32::from_rgba_premultiplied(2, 2, 2, 20), hover: Color32::from_rgb(0xEF, 0xC8, 0x00), - avatar_palette: AVATARS, + avatar_pairs: AVATAR_PAIRS, dark_base: false, }; @@ -276,8 +302,13 @@ pub fn ink_for(bg: Color32) -> Color32 { } } -/// Avatar background color for a hue index. -pub fn avatar_color(hue: usize) -> Color32 { - let palette = &tokens().avatar_palette; - palette[hue % palette.len()] +/// Avatar (background, ink) pair for a hue index. +pub fn avatar_pair(hue: usize) -> (Color32, Color32) { + let pairs = &tokens().avatar_pairs; + pairs[hue % pairs.len()] +} + +/// Number of avatar color pairs (hue derivation modulus). +pub fn avatar_pairs_len() -> usize { + tokens().avatar_pairs.len() } diff --git a/src/gui/views/goblin/avatars.rs b/src/gui/views/goblin/avatars.rs new file mode 100644 index 00000000..a783e7d7 --- /dev/null +++ b/src/gui/views/goblin/avatars.rs @@ -0,0 +1,177 @@ +// Copyright 2026 The Goblin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Texture layer over the avatar disk cache: hands the UI ready +//! [`egui::TextureHandle`]s for usernames, fetching stale entries from the +//! NIP-05 server on background threads. Textures are only created on the UI +//! thread; workers send raw PNG bytes back over a channel. + +use std::collections::{HashMap, HashSet}; +use std::sync::mpsc::{Receiver, Sender, channel}; + +use crate::nostr::avatar::AvatarCache; +use crate::nostr::nip05; +use crate::settings::Settings; + +/// Worker outcome for one name's avatar probe. +enum Fetched { + /// A custom avatar (content hash, png bytes). + Found(String, Vec), + /// The server confirmed the name has no avatar. + Absent, + /// The probe failed (network/Tor) — do NOT cache; retry later. + Failed, +} +type FetchResult = (String, Fetched); + +pub struct AvatarTextures { + cache: AvatarCache, + /// Ready textures; `None` records a known letter-fallback (no avatar). + textures: HashMap>, + inflight: HashSet, + tx: Sender, + rx: Receiver, +} + +impl Default for AvatarTextures { + fn default() -> Self { + let (tx, rx) = channel(); + Self { + cache: AvatarCache::new(Settings::base_path(Some("cache/avatars".to_string()))), + textures: HashMap::new(), + inflight: HashSet::new(), + tx, + rx, + } + } +} + +fn decode(png: &[u8]) -> Option { + let img = image::load_from_memory(png).ok()?.to_rgba8(); + Some(egui::ColorImage::from_rgba_unmultiplied( + [img.width() as usize, img.height() as usize], + img.as_raw(), + )) +} + +impl AvatarTextures { + /// Texture for a bare username (no `@`), if it has a custom avatar. + /// Triggers a background refresh when the cache entry is stale. + pub fn texture_for( + &mut self, + ctx: &egui::Context, + server: &str, + name: &str, + ) -> Option { + self.drain(ctx); + let name = name.trim_start_matches('@').to_lowercase(); + if name.is_empty() { + return None; + } + if let Some(t) = self.textures.get(&name).cloned() { + // A known state (texture or confirmed-absent); refresh if stale. + if self.cache.stale(&name) { + self.spawn_fetch(server, &name); + } + return t; + } + // Disk cache hit → texture now, refresh in background if stale. + if let Some((_, bytes)) = self.cache.cached(&name) { + let tex = decode(&bytes) + .map(|img| ctx.load_texture(format!("avatar_{name}"), img, Default::default())); + self.textures.insert(name.clone(), tex.clone()); + if self.cache.stale(&name) { + self.spawn_fetch(server, &name); + } + return tex; + } + if self.cache.stale(&name) { + self.spawn_fetch(server, &name); + } else { + // Fresh negative entry: letter fallback without re-probing. + self.textures.insert(name.clone(), None); + } + None + } + + /// Install the just-uploaded avatar without waiting for a round-trip. + pub fn set_own(&mut self, ctx: &egui::Context, name: &str, hash: &str, png: &[u8]) { + let name = name.trim_start_matches('@').to_lowercase(); + self.cache.store(&name, hash, png); + let tex = decode(png) + .map(|img| ctx.load_texture(format!("avatar_{name}"), img, Default::default())); + self.textures.insert(name, tex); + } + + /// Forget a name (released or rotated away). + pub fn invalidate(&mut self, name: &str) { + let name = name.trim_start_matches('@').to_lowercase(); + self.cache.remove(&name); + self.textures.remove(&name); + } + + fn drain(&mut self, ctx: &egui::Context) { + while let Ok((name, fetched)) = self.rx.try_recv() { + self.inflight.remove(&name); + match fetched { + Fetched::Found(hash, png) => { + self.cache.store(&name, &hash, &png); + let tex = decode(&png).map(|img| { + ctx.load_texture(format!("avatar_{name}"), img, Default::default()) + }); + self.textures.insert(name, tex); + } + Fetched::Absent => { + self.cache.mark_absent(&name); + self.textures.insert(name, None); + } + // Network/Tor failure: leave the entry stale so the next + // frame retries once a circuit is healthy. Never cache it as + // a confirmed "no avatar". + Fetched::Failed => {} + } + ctx.request_repaint(); + } + } + + fn spawn_fetch(&mut self, server: &str, name: &str) { + if self.inflight.contains(name) { + return; + } + self.inflight.insert(name.to_string()); + let tx = self.tx.clone(); + let server = server.to_string(); + let name = name.to_string(); + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(_) => return, + }; + let fetched = rt.block_on(async { + match nip05::fetch_profile(&server, &name).await { + Some(Some(hash)) => match nip05::fetch_avatar(&server, &hash).await { + Some(png) => Fetched::Found(hash, png), + None => Fetched::Failed, + }, + Some(None) => Fetched::Absent, + None => Fetched::Failed, + } + }); + let _ = tx.send((name, fetched)); + }); + } +} diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index f268b6bc..841c33e9 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -40,7 +40,7 @@ pub fn contact_title(store: &NostrStore, npub: &str) -> (String, usize) { if let Some(contact) = store.contact(npub) { (display_name(&contact), contact.hue as usize) } else { - let hue = usize::from_str_radix(&npub[..2.min(npub.len())], 16).unwrap_or(0) % 7; + let hue = hue_of(&npub); (short_npub(npub), hue) } } @@ -64,6 +64,13 @@ pub fn display_name(contact: &Contact) -> String { } /// Short npub display (npub1abcd…wxyz) from a hex pubkey. +/// Avatar hue index derived from a hex pubkey (stable per identity, spread +/// across the full color-pair palette). +pub fn hue_of(hex: &str) -> usize { + usize::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0) + % crate::gui::theme::avatar_pairs_len() +} + pub fn short_npub(hex: &str) -> String { use nostr_sdk::{PublicKey, ToBech32}; if let Ok(pk) = PublicKey::from_hex(hex) { @@ -118,7 +125,10 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem { } else { "Sent".to_string() }; - (label, (tx.data.id as usize) % 7) + ( + label, + (tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(), + ) }; let note = meta.as_ref().and_then(|m| m.note.clone()); diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index b2bbf17a..f85e2155 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -14,6 +14,7 @@ //! The Goblin Cash App-style wallet surface for an open wallet. +pub mod avatars; pub mod data; pub mod onboarding; pub mod send; @@ -74,6 +75,14 @@ pub struct GoblinWalletView { /// Transient "Copied" feedback on the Receive buttons: which one /// (0 = npub, 1 = grin address) and when it was clicked. receive_copied: Option<(u8, std::time::Instant)>, + /// Avatar texture layer (disk cache + background fetches). + avatars: avatars::AvatarTextures, + /// Profile-picture upload in flight. + avatar_busy: bool, + /// Upload worker result: (server hash, processed png) or error. + avatar_slot: std::sync::Arc), String>>>>, + /// Last upload outcome message (cleared on the next attempt). + avatar_msg: Option, } /// Sub-pages of the Settings tab. @@ -103,6 +112,10 @@ impl Default for GoblinWalletView { relay_edit: Vec::new(), relay_input: String::new(), receive_copied: None, + avatars: avatars::AvatarTextures::default(), + avatar_busy: false, + avatar_slot: std::sync::Arc::new(std::sync::Mutex::new(None)), + avatar_msg: None, } } } @@ -165,11 +178,14 @@ struct ClaimState { available: Option, result: std::sync::Arc>>, message: Option, + /// The are-you-sure gate before releasing a username. + confirm_release: bool, } enum ClaimMsg { Availability(crate::nostr::nip05::Availability), Registered(String), + Released, Error(String), } @@ -195,6 +211,7 @@ impl Default for ClaimState { available: None, result: std::sync::Arc::new(std::sync::Mutex::new(None)), message: None, + confirm_release: false, } } } @@ -244,7 +261,7 @@ impl GoblinWalletView { // Send flow takes the full surface when active. if let Some(send) = &mut self.send { - let done = send.ui(ui, wallet, cb); + let done = send.ui(ui, wallet, cb, &mut self.avatars); if done { self.send = None; } @@ -483,12 +500,12 @@ impl GoblinWalletView { ui.with_layout(Layout::bottom_up(Align::Min), |ui| { let width = ui.available_width(); let bottom = ui.allocate_ui_with_layout( - Vec2::new(width, 148.0), + Vec2::new(width, 196.0), Layout::top_down(Align::Min), |ui| { self.node_card_ui(ui, wallet); ui.add_space(8.0); - let (handle, connected) = wallet + let (handle, connected, npub_hex) = wallet .nostr_service() .map(|s| { let id = s.identity.read(); @@ -497,13 +514,15 @@ impl GoblinWalletView { .clone() .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) .unwrap_or_else(|| data::short_npub(&hex_of(&id.npub))); - (h, s.is_connected()) + (h, s.is_connected(), hex_of(&id.npub)) }) - .unwrap_or_else(|| ("Anonymous".to_string(), false)); + .unwrap_or_else(|| ("Anonymous".to_string(), false, String::new())); + let hue = data::hue_of(&npub_hex); + let tex = self.handle_tex(ui.ctx(), wallet, &handle); w::card(ui, |ui| { ui.set_min_width(ui.available_width()); ui.horizontal(|ui| { - w::avatar(ui, &handle, 36.0, 6); + w::avatar_any(ui, &handle, 36.0, hue, tex.as_ref()); ui.add_space(10.0); ui.vertical(|ui| { ui.label( @@ -532,6 +551,23 @@ impl GoblinWalletView { }); } + /// Avatar texture for a display handle ("@name"); None for non-handles + /// (anonymous identities keep their letter puck). + fn handle_tex( + &mut self, + ctx: &egui::Context, + wallet: &Wallet, + handle: &str, + ) -> Option { + if !handle.starts_with('@') { + return None; + } + let server = wallet + .nostr_service() + .map(|s| s.config.read().nip05_server())?; + self.avatars.texture_for(ctx, &server, handle) + } + /// Compact node status card: sync state dot, block height, connection. fn node_card_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { let t = theme::tokens(); @@ -571,13 +607,26 @@ impl GoblinWalletView { .font(FontId::new(14.0, fonts::semibold())) .color(t.surface_text), ); - // Single line: wrapping strands the "·" separator at the - // end of the first line in the narrow sidebar card. + // Three lines: status, block height, then the node host + // on its own line so it never truncates the height. + let height = wallet + .get_data() + .map(|d| d.info.last_confirmed_height) + .unwrap_or(0); + ui.label( + RichText::new(if height > 0 { + format!("Block {}", fmt_thousands(height)) + } else { + "Waiting for chain…".to_string() + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_dim), + ); ui.add( egui::Label::new( - RichText::new(node_summary(wallet)) + RichText::new(node_host(wallet)) .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_dim), + .color(t.surface_text_mute), ) .truncate(), ); @@ -600,6 +649,20 @@ impl GoblinWalletView { // Mobile header: wordmark left, avatar (opens settings) right. if !wide { ui.add_space(10.0); + let (header_handle, header_hex) = wallet + .nostr_service() + .map(|s| { + let id = s.identity.read(); + let h = id + .nip05 + .clone() + .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) + .unwrap_or_else(|| "N".to_string()); + (h, hex_of(&id.npub)) + }) + .unwrap_or_else(|| ("N".to_string(), String::new())); + let header_hue = data::hue_of(&header_hex); + let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle); ui.horizontal(|ui| { widgets_logo(ui); ui.add_space(8.0); @@ -609,17 +672,15 @@ impl GoblinWalletView { .color(theme::tokens().text), ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let handle = wallet - .nostr_service() - .map(|s| { - let id = s.identity.read(); - id.nip05 - .clone() - .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) - .unwrap_or_else(|| "N".to_string()) - }) - .unwrap_or_else(|| "N".to_string()); - if w::avatar(ui, &handle, 36.0, 6).clicked() { + if w::avatar_any( + ui, + &header_handle, + 36.0, + header_hue, + header_tex.as_ref(), + ) + .clicked() + { self.tab = Tab::Me; } // Scan-to-pay, left of the avatar per the refs. @@ -693,6 +754,10 @@ impl GoblinWalletView { if peers.is_empty() { return; } + let texs: Vec> = peers + .iter() + .map(|(name, _, _)| self.handle_tex(ui.ctx(), wallet, name)) + .collect(); w::kicker(ui, "Recent"); ui.add_space(12.0); ScrollArea::horizontal() @@ -700,9 +765,9 @@ impl GoblinWalletView { .auto_shrink([false, true]) .show(ui, |ui| { ui.horizontal(|ui| { - for (name, hue, npub) in &peers { + for ((name, hue, npub), tex) in peers.iter().zip(texs.iter()) { ui.vertical(|ui| { - let resp = w::avatar(ui, name, 48.0, *hue); + let resp = w::avatar_any(ui, name, 48.0, *hue, tex.as_ref()); ui.add_space(6.0); let short: String = name.chars().take(6).collect(); ui.label(RichText::new(short).font(FontId::new(12.0, fonts::medium()))); @@ -864,7 +929,7 @@ impl GoblinWalletView { &mut self, ui: &mut egui::Ui, item: &ActivityItem, - _wallet: &Wallet, + wallet: &Wallet, _cb: &dyn PlatformCallbacks, ) { let sign = if item.incoming { "+ " } else { "− " }; @@ -875,6 +940,7 @@ impl GoblinWalletView { (None, true) => View::format_time(item.time), (None, false) => "pending".to_string(), }; + let tex = self.handle_tex(ui.ctx(), wallet, &item.title); w::activity_row( ui, &item.title, @@ -883,6 +949,7 @@ impl GoblinWalletView { &amount, item.incoming, item.system, + tex.as_ref(), ); } @@ -897,9 +964,10 @@ impl GoblinWalletView { .nostr_service() .map(|s| data::contact_title(&s.store, &req.npub)) .unwrap_or_else(|| (data::short_npub(&req.npub), 0)); + let tex = self.handle_tex(ui.ctx(), wallet, &name); w::card(ui, |ui| { ui.horizontal(|ui| { - w::avatar(ui, &name, 40.0, hue); + w::avatar_any(ui, &name, 40.0, hue, tex.as_ref()); ui.add_space(12.0); ui.vertical(|ui| { ui.label( @@ -1119,23 +1187,65 @@ impl GoblinWalletView { ui.add_space(16.0); // Profile card. - let (handle, npub, connected) = wallet + let (handle, npub, connected, bare_name, npub_hex) = wallet .nostr_service() .map(|s| { let identity = s.identity.read(); - let handle = identity + let bare = identity .nip05 .clone() - .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) + .map(|n| n.split('@').next().unwrap_or("").to_string()); + let handle = bare + .clone() + .map(|n| format!("@{n}")) .unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub))); - (handle, s.npub(), s.is_connected()) + ( + handle, + s.npub(), + s.is_connected(), + bare, + hex_of(&identity.npub), + ) }) - .unwrap_or_else(|| ("Anonymous".to_string(), String::new(), false)); + .unwrap_or_else(|| { + ( + "Anonymous".to_string(), + String::new(), + false, + None, + String::new(), + ) + }); + + // Poll a finished avatar upload. + if let Some(res) = self.avatar_slot.lock().unwrap().take() { + self.avatar_busy = false; + match res { + Ok((hash, png)) => { + if let Some(b) = bare_name.as_deref() { + self.avatars.set_own(ui.ctx(), b, &hash, &png); + } + self.avatar_msg = Some("Profile picture updated".to_string()); + } + Err(e) => self.avatar_msg = Some(e), + } + } + let hue = data::hue_of(&npub_hex); + let own_tex = bare_name + .as_deref() + .and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle)); + let mut pick_picture = false; + let avatar_busy = self.avatar_busy; + let avatar_msg = self.avatar_msg.clone(); w::card(ui, |ui| { ui.set_min_width(ui.available_width()); ui.horizontal(|ui| { - w::avatar(ui, &handle, 56.0, 6); + 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; + } ui.add_space(14.0); ui.vertical(|ui| { ui.label( @@ -1143,11 +1253,21 @@ impl GoblinWalletView { .font(FontId::new(17.0, fonts::bold())) .color(t.surface_text), ); - if npub.len() > 18 { - // Standard truncation: head 12 chars … tail 6. + if !npub.is_empty() { + // Full npub when it fits on one line, else head…tail. + let full = ui.painter().layout_no_wrap( + npub.clone(), + FontId::new(11.0, fonts::mono()), + t.surface_text_mute, + ); + let text = if full.size().x <= ui.available_width() { + npub.clone() + } else { + format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) + }; ui.label( - RichText::new(format!("{}…{}", &npub[..12], &npub[npub.len() - 6..])) - .font(FontId::new(12.0, fonts::mono())) + RichText::new(text) + .font(FontId::new(11.0, fonts::mono())) .color(t.surface_text_mute), ); } @@ -1163,7 +1283,43 @@ impl GoblinWalletView { ); }); }); + if avatar_busy { + ui.add_space(6.0); + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new("Uploading picture…") + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } else if let Some(msg) = &avatar_msg { + ui.add_space(6.0); + let good = msg.starts_with("Profile picture"); + ui.label( + RichText::new(msg) + .font(FontId::new(12.5, fonts::regular())) + .color(if good { t.pos } else { t.neg }), + ); + } }); + if pick_picture { + match bare_name.clone() { + Some(name) => { + if let Some(path) = cb.pick_image_file() { + self.avatar_busy = true; + self.avatar_msg = None; + start_avatar_upload(self.avatar_slot.clone(), path, name, wallet); + } + } + None => { + self.avatar_msg = + Some("Claim a username first — pictures ride on it".to_string()); + } + } + } ui.add_space(16.0); // Mark the scroll boundary: rows clipping under the pinned profile @@ -1178,39 +1334,16 @@ impl GoblinWalletView { ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { - settings_group(ui, "Privacy", |ui| { - settings_row(ui, "Tor routing", "All payments routed over Tor"); - // Tap to cycle the incoming-payment accept policy. - if settings_row_btn(ui, "Auto-accept", accept_policy_label(wallet)) { - cycle_accept_policy(wallet); - } - // Tap to toggle the fiat (USD) preview. - let fiat = if crate::AppConfig::fiat_preview() { - "On" - } else { - "Off" - }; - if settings_row_btn(ui, "Fiat preview (USD)", fiat) { - crate::AppConfig::toggle_fiat_preview(); - } - }); - ui.add_space(16.0); - - // Identity: claim a username, copy npub, back up the secret. - let is_anon = wallet - .nostr_service() - .map(|s| s.identity.read().anonymous) - .unwrap_or(true); + // Identity: username, picture, keys — first because it is the + // face of the wallet. w::kicker(ui, "Identity"); ui.add_space(8.0); + if self.claim.is_none() { + self.claim = Some(ClaimState::default()); + } + self.claim_ui(ui, wallet); + ui.add_space(8.0); w::card(ui, |ui| { - if is_anon { - if settings_row_btn(ui, "Claim a username", USER_CIRCLE) { - self.claim = Some(ClaimState::default()); - } - } else { - settings_row(ui, "Username", &handle); - } if !npub.is_empty() { if settings_row_btn(ui, "Copy npub (public)", COPY) { cb.copy_string_to_buffer(npub.clone()); @@ -1261,10 +1394,6 @@ impl GoblinWalletView { .font(FontId::new(12.0, fonts::regular())) .color(t.text_mute), ); - if self.claim.is_some() { - ui.add_space(8.0); - self.claim_ui(ui, wallet); - } if self.rotate.is_some() { ui.add_space(8.0); self.rotate_ui(ui, wallet, cb); @@ -1274,20 +1403,6 @@ impl GoblinWalletView { self.import_nsec_ui(ui, wallet); } - ui.add_space(16.0); - w::kicker(ui, "Appearance"); - ui.add_space(8.0); - w::card(ui, |ui| { - let theme_label = match crate::AppConfig::theme() { - crate::gui::theme::ThemeKind::Light => "Light", - crate::gui::theme::ThemeKind::Dark => "Dark", - crate::gui::theme::ThemeKind::Yellow => "Yellow", - }; - if settings_row_btn(ui, "Theme", theme_label) { - cycle_theme(ui.ctx()); - } - }); - ui.add_space(16.0); let mut open_relays = false; let mut open_node = false; @@ -1317,6 +1432,38 @@ impl GoblinWalletView { self.settings_page = SettingsPage::Node; } + ui.add_space(16.0); + settings_group(ui, "Privacy", |ui| { + settings_row(ui, "Tor routing", "All payments routed over Tor"); + // Tap to cycle the incoming-payment accept policy. + if settings_row_btn(ui, "Auto-accept", accept_policy_label(wallet)) { + cycle_accept_policy(wallet); + } + // Tap to toggle the fiat (USD) preview. + let fiat = if crate::AppConfig::fiat_preview() { + "On" + } else { + "Off" + }; + if settings_row_btn(ui, "Fiat preview (USD)", fiat) { + crate::AppConfig::toggle_fiat_preview(); + } + }); + + ui.add_space(16.0); + w::kicker(ui, "Appearance"); + ui.add_space(8.0); + w::card(ui, |ui| { + let theme_label = match crate::AppConfig::theme() { + crate::gui::theme::ThemeKind::Light => "Light", + crate::gui::theme::ThemeKind::Dark => "Dark", + crate::gui::theme::ThemeKind::Yellow => "Yellow", + }; + if settings_row_btn(ui, "Theme", theme_label) { + cycle_theme(ui.ctx()); + } + }); + ui.add_space(16.0); w::kicker(ui, "Archive"); ui.add_space(8.0); @@ -1740,11 +1887,13 @@ impl GoblinWalletView { receiving. There is no derivation chain between them.", "• The new key is NOT recoverable from your seed — back \ up the new nsec right after rotating.", - "• Your @username moves to the new key automatically.", + "• Your @username is RELEASED and your profile picture \ + deleted — claim the same or a new name right after \ + (anyone else can grab it too once it's free).", "• Payments still in flight to the old key WILL be \ disrupted — wait for pending payments to finish first.", "• Contacts who saved your npub directly must re-find \ - you via your @username.", + you — share your new npub or re-claimed @username.", ] { ui.label( RichText::new(line) @@ -2108,143 +2257,228 @@ impl GoblinWalletView { /// Inline username-claim widget (availability check + register over Tor). fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { let t = theme::tokens(); - let claim = self.claim.as_mut().unwrap(); - // Poll the worker result. - if let Some(msg) = claim.result.lock().unwrap().take() { - claim.checking = false; - match msg { - ClaimMsg::Availability(avail) => { - let (available, msg) = availability_feedback(avail); - claim.available = available; - claim.message = Some(msg.to_string()); - } - ClaimMsg::Registered(nip05) => { - claim.message = Some(format!("Registered {}", nip05)); - // Persist nip05 on the identity and republish. - if let Some(s) = wallet.nostr_service() { - { - let mut id = s.identity.write(); - id.nip05 = Some(nip05.clone()); - id.anonymous = false; + // Poll the worker result; avatar invalidation happens after the + // claim borrow is released. + let mut invalidate_avatar: Option = None; + { + let claim = self.claim.as_mut().unwrap(); + if let Some(msg) = claim.result.lock().unwrap().take() { + claim.checking = false; + match msg { + ClaimMsg::Availability(avail) => { + let (available, msg) = availability_feedback(avail); + claim.available = available; + claim.message = Some(msg.to_string()); + } + ClaimMsg::Registered(nip05) => { + let name = nip05.split('@').next().unwrap_or("").to_string(); + claim.message = Some(format!("Registered {name}")); + claim.available = Some(true); + claim.input.clear(); + // Persist nip05 on the identity and republish. + if let Some(s) = wallet.nostr_service() { + { + let mut id = s.identity.write(); + id.nip05 = Some(nip05.clone()); + id.anonymous = false; + } + s.save_identity(); } - s.save_identity(); + } + ClaimMsg::Released => { + claim.message = Some("Released — the name is up for grabs".to_string()); + claim.available = None; + claim.confirm_release = false; + if let Some(s) = wallet.nostr_service() { + let name = { + let mut id = s.identity.write(); + let n = id.nip05.take(); + id.anonymous = true; + n + }; + s.save_identity(); + invalidate_avatar = + name.map(|n| n.split('@').next().unwrap_or("").to_string()); + } + } + ClaimMsg::Error(e) => { + claim.available = Some(false); + claim.message = Some(e); } } - ClaimMsg::Error(e) => claim.message = Some(e), } } + if let Some(name) = invalidate_avatar { + self.avatars.invalidate(&name); + } + let claim = self.claim.as_mut().unwrap(); + + let registered: Option = wallet + .nostr_service() + .and_then(|s| s.identity.read().nip05.clone()) + .map(|n| n.split('@').next().unwrap_or("").to_string()); + w::card(ui, |ui| { - ui.label( - RichText::new("Pick a username") - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(8.0); - ui.horizontal(|ui| { - ui.label( - RichText::new("@") - .font(FontId::new(16.0, fonts::semibold())) - .color(t.surface_text), - ); - let edit = egui::TextEdit::singleline(&mut claim.input) - .hint_text("yourname") - .desired_width(ui.available_width() - 20.0) - .text_color(t.surface_text) - .frame(false); - if ui.add(edit).changed() { - claim.available = None; - claim.message = None; - } - }); - ui.add_space(4.0); - ui.label( - RichText::new("Shown as @you. Public on goblin.st. Payments stay encrypted.") - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_mute), - ); - if let Some(msg) = &claim.message { - ui.add_space(6.0); - ui.label( - RichText::new(msg) - .font(FontId::new(13.0, fonts::regular())) - .color(match claim.available { - Some(false) => t.neg, - Some(true) => t.pos, - None => t.surface_text_dim, - }), - ); - } - ui.add_space(10.0); - let name = claim.input.trim().to_lowercase(); - let valid = name.len() >= 3 && name.len() <= 30; - if claim.checking { - ui.horizontal(|ui| { - View::small_loading_spinner(ui); - ui.add_space(8.0); - ui.label(RichText::new("Working…").color(t.surface_text_dim)); - }); - ui.ctx().request_repaint(); - } else { - ui.horizontal(|ui| { - let half = (ui.available_width() - 10.0) / 2.0; - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 44.0), - )), - |ui| { - ui.add_enabled_ui(valid, |ui| { - if w::big_action_on_card(ui, "Check").clicked() { - start_claim_check(claim, &name, wallet); - } - }); - }, + ui.set_min_width(ui.available_width()); + if let Some(name) = registered { + if claim.confirm_release { + // The are-you-sure gate. + ui.label( + RichText::new(format!("Release @{name}?")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(4.0); + ui.label( + RichText::new( + "It's up for grabs the moment it's free — anyone can \ + claim it, including the next key you rotate to. Your \ + profile picture is deleted with it.", + ) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), ); ui.add_space(10.0); - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 44.0), - )), - |ui| { - ui.add_enabled_ui(valid && claim.available == Some(true), |ui| { - if w::big_action(ui, "Claim", false).clicked() { - start_claim_register(claim, &name, wallet); - } - }); - }, + if claim.checking { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label(RichText::new("Releasing…").color(t.surface_text_dim)); + }); + ui.ctx().request_repaint(); + } else { + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, "Keep it").clicked() { + claim.confirm_release = false; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card_ink(ui, "Release it", t.neg).clicked() + { + start_release(claim, &name, wallet); + } + }, + ); + }); + } + } else { + ui.label( + RichText::new("Username") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), ); + ui.add_space(4.0); + ui.label( + RichText::new(format!("@{name}")) + .font(FontId::new(20.0, fonts::bold())) + .color(t.surface_text), + ); + ui.add_space(4.0); + ui.label( + RichText::new( + "Shown as @you. Public on goblin.st. Payments stay encrypted.", + ) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + if let Some(msg) = &claim.message { + ui.add_space(6.0); + ui.label( + RichText::new(msg) + .font(FontId::new(13.0, fonts::regular())) + .color(match claim.available { + Some(false) => t.neg, + Some(true) => t.pos, + None => t.surface_text_dim, + }), + ); + } + ui.add_space(10.0); + if w::big_action_on_card_ink(ui, "Release username", t.neg).clicked() { + claim.confirm_release = true; + claim.message = None; + } + } + } else { + ui.label( + RichText::new("Pick a username — optional") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label( + RichText::new("@") + .font(FontId::new(16.0, fonts::semibold())) + .color(t.surface_text), + ); + let edit = egui::TextEdit::singleline(&mut claim.input) + .hint_text("yourname") + .desired_width(ui.available_width() - 20.0) + .text_color(t.surface_text) + .frame(false); + if ui.add(edit).changed() { + claim.available = None; + claim.message = None; + } }); + ui.add_space(4.0); + ui.label( + RichText::new("Shown as @you. Public on goblin.st. Payments stay encrypted.") + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + if let Some(msg) = &claim.message { + ui.add_space(6.0); + ui.label( + RichText::new(msg) + .font(FontId::new(13.0, fonts::regular())) + .color(match claim.available { + Some(false) => t.neg, + Some(true) => t.pos, + None => t.surface_text_dim, + }), + ); + } + ui.add_space(10.0); + let name = claim.input.trim().to_lowercase(); + let valid = name.len() >= 3 && name.len() <= 30; + if claim.checking { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label(RichText::new("Working…").color(t.surface_text_dim)); + }); + ui.ctx().request_repaint(); + } else { + ui.add_enabled_ui(valid, |ui| { + if w::big_action(ui, "Claim", false).clicked() { + start_claim_flow(claim, &name, wallet); + } + }); + } } }); } } -/// Spawn the availability check on a worker thread. -fn start_claim_check(claim: &mut ClaimState, name: &str, wallet: &Wallet) { - let server = wallet - .nostr_service() - .map(|s| s.config.read().nip05_server()) - .unwrap_or_default(); - claim.checking = true; - let slot = claim.result.clone(); - let name = name.to_string(); - std::thread::spawn(move || { - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(_) => return, - }; - let avail = rt.block_on(crate::nostr::nip05::check_availability(&server, &name)); - let msg = ClaimMsg::Availability(avail); - *slot.lock().unwrap() = Some(msg); - }); -} - -/// Spawn the NIP-98 registration on a worker thread. -fn start_claim_register(claim: &mut ClaimState, name: &str, wallet: &Wallet) { +/// Spawn the combined claim: availability check first, then registration +/// in the same worker — one button, no separate Check step. +fn start_claim_flow(claim: &mut ClaimState, name: &str, wallet: &Wallet) { let Some(service) = wallet.nostr_service() else { return; }; @@ -2257,6 +2491,8 @@ fn start_claim_register(claim: &mut ClaimState, name: &str, wallet: &Wallet) { None => return, }; claim.checking = true; + claim.message = None; + claim.available = None; let slot = claim.result.clone(); let name = name.to_string(); std::thread::spawn(move || { @@ -2267,21 +2503,96 @@ fn start_claim_register(claim: &mut ClaimState, name: &str, wallet: &Wallet) { Ok(rt) => rt, Err(_) => return, }; - let res = rt.block_on(crate::nostr::nip05::register(&server, &name, &keys)); - let msg = match res { - crate::nostr::nip05::RegisterResult::Ok(nip05) => ClaimMsg::Registered(nip05), - crate::nostr::nip05::RegisterResult::Conflict(_) => { - ClaimMsg::Error("That username was just taken".into()) - } - crate::nostr::nip05::RegisterResult::Rejected(e) => ClaimMsg::Error(e), - crate::nostr::nip05::RegisterResult::Network => { - ClaimMsg::Error("Network error — try again".into()) + let msg = rt.block_on(async { + use crate::nostr::nip05::{Availability, RegisterResult, check_availability, register}; + match check_availability(&server, &name).await { + Availability::Available => match register(&server, &name, &keys).await { + RegisterResult::Ok(nip05) => ClaimMsg::Registered(nip05), + RegisterResult::Conflict(_) => { + ClaimMsg::Error("That username was just taken".into()) + } + RegisterResult::Rejected(e) => ClaimMsg::Error(e), + RegisterResult::Network => ClaimMsg::Error( + "Couldn't reach goblin.st — connection hiccup. Try again.".into(), + ), + }, + other => ClaimMsg::Availability(other), } + }); + *slot.lock().unwrap() = Some(msg); + }); +} + +/// Spawn the username release; the server deletes its avatar with it. +fn start_release(claim: &mut ClaimState, name: &str, wallet: &Wallet) { + let Some(service) = wallet.nostr_service() else { + return; + }; + let server = service.config.read().nip05_server(); + let keys = match service + .nsec() + .and_then(|nsec| nostr_sdk::Keys::parse(&nsec).ok()) + { + Some(k) => k, + None => return, + }; + claim.checking = true; + claim.message = None; + let slot = claim.result.clone(); + let name = name.to_string(); + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(_) => return, + }; + let msg = match rt.block_on(crate::nostr::nip05::unregister(&server, &name, &keys)) { + Ok(()) => ClaimMsg::Released, + Err(e) => ClaimMsg::Error(format!("Couldn't release: {e}")), }; *slot.lock().unwrap() = Some(msg); }); } +/// Process a picked picture and upload it as the avatar for an owned name. +fn start_avatar_upload( + slot: std::sync::Arc), String>>>>, + path: String, + name: String, + wallet: &Wallet, +) { + let Some(service) = wallet.nostr_service() else { + return; + }; + let server = service.config.read().nip05_server(); + let keys = match service + .nsec() + .and_then(|nsec| nostr_sdk::Keys::parse(&nsec).ok()) + { + Some(k) => k, + None => return, + }; + std::thread::spawn(move || { + let res = (|| { + let png = crate::nostr::avatar::process_avatar_file(&path)?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + let hash = rt.block_on(crate::nostr::nip05::upload_avatar( + &server, + &name, + &keys, + png.clone(), + ))?; + Ok((hash, png)) + })(); + *slot.lock().unwrap() = Some(res); + }); +} + /// Draw the small Goblin mascot mark. pub fn widgets_logo(ui: &mut egui::Ui) { widgets_logo_sized(ui, 22.0); @@ -2479,6 +2790,18 @@ fn relay_summary(wallet: &Wallet) -> String { /// Compute a fiat preview line for the balance, when a rate is available. /// One-line node summary: "Block 1,847,221 · main.gri.mw · Tor". +/// Bare node host (or "integrated node") for the sidebar card's third line. +fn node_host(wallet: &Wallet) -> String { + match wallet.get_current_connection() { + crate::wallet::types::ConnectionMethod::Integrated => "integrated node".to_string(), + crate::wallet::types::ConnectionMethod::External(_, url) => url + .replace("https://", "") + .replace("http://", "") + .trim_end_matches('/') + .to_string(), + } +} + fn node_summary(wallet: &Wallet) -> String { let height = wallet .get_data() diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index 381cd677..e59540f2 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -32,7 +32,7 @@ use crate::wallet::types::{ConnectionMethod, PhraseMode, PhraseSize}; use crate::wallet::{ConnectionsConfig, ExternalConnection, Wallet, WalletList}; use super::widgets::{self as w}; -use super::{ClaimMsg, ClaimState, start_claim_register}; +use super::{ClaimMsg, ClaimState, start_claim_flow}; /// Identifier for the recovery-phrase QR scan [`Modal`]. const OB_PHRASE_SCAN_MODAL: &'static str = "ob_phrase_scan_modal"; @@ -750,6 +750,7 @@ impl OnboardingContent { s.save_identity(); } } + ClaimMsg::Released => {} ClaimMsg::Error(e) => { self.claim.available = Some(false); self.claim.message = Some(e); @@ -822,7 +823,7 @@ impl OnboardingContent { } else { ui.add_enabled_ui(valid && connected, |ui| { if w::big_action_on_card(ui, "Claim username").clicked() { - start_claim_register(&mut self.claim, &name, &wallet); + start_claim_flow(&mut self.claim, &name, &wallet); } }); if !connected { diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 1e9a1837..a1b999d6 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -27,9 +27,26 @@ use crate::nostr::nip05; use crate::wallet::Wallet; use crate::wallet::types::WalletTask; -use super::data::{display_name, recent_peers, short_npub}; +use super::avatars::AvatarTextures; +use super::data::{self, display_name, recent_peers, short_npub}; use super::widgets::{self as w, HoldToSend}; +/// Avatar texture for a display handle ("@name"), if one is cached. +fn tex_for( + avatars: &mut AvatarTextures, + ctx: &egui::Context, + wallet: &Wallet, + name: &str, +) -> Option { + if !name.starts_with('@') { + return None; + } + let server = wallet + .nostr_service() + .map(|s| s.config.read().nip05_server())?; + avatars.texture_for(ctx, &server, name) +} + /// Stage of the send flow. #[derive(PartialEq, Eq)] enum Stage { @@ -92,7 +109,7 @@ impl Default for SendFlow { impl SendFlow { /// Pre-fill a contact and skip to amount entry. pub fn prefill_contact(&mut self, name: String, npub: String) { - let hue = usize::from_str_radix(&npub[..2.min(npub.len())], 16).unwrap_or(0) % 7; + let hue = data::hue_of(&npub); self.recipient = Some(Recipient { name, npub, hue }); self.stage = Stage::Amount; } @@ -111,7 +128,13 @@ impl SendFlow { } /// Render the flow. Returns true when the flow is finished (close it). - pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) -> bool { + pub fn ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + avatars: &mut AvatarTextures, + ) -> bool { let t = theme::tokens(); let mut done = false; egui::CentralPanel::default() @@ -134,9 +157,9 @@ impl SendFlow { ui, crate::gui::views::Content::SIDE_PANEL_WIDTH * 1.2, |ui| match self.stage { - Stage::Recipient => done = self.recipient_ui(ui, wallet, cb), - Stage::Amount => done = self.amount_ui(ui, wallet), - Stage::Review => done = self.review_ui(ui, wallet), + Stage::Recipient => done = self.recipient_ui(ui, wallet, cb, avatars), + Stage::Amount => done = self.amount_ui(ui, wallet, avatars), + Stage::Review => done = self.review_ui(ui, wallet, avatars), Stage::Sending => self.sending_ui(ui, wallet), Stage::Success => done = self.success_ui(ui), Stage::Failed => done = self.failed_ui(ui, wallet), @@ -176,6 +199,7 @@ impl SendFlow { ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks, + avatars: &mut AvatarTextures, ) -> bool { let t = theme::tokens(); @@ -288,6 +312,10 @@ impl SendFlow { ); ui.add_space(8.0); let peers = recent_peers(wallet, 20); + let texs: Vec> = peers + .iter() + .map(|(name, _, _)| tex_for(avatars, ui.ctx(), wallet, name)) + .collect(); ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { @@ -299,9 +327,17 @@ impl SendFlow { .color(t.text_dim), ); } - for (name, hue, npub) in peers { - let resp = - w::activity_row(ui, &name, &short_npub(&npub), hue, "", false, false); + for ((name, hue, npub), tex) in peers.into_iter().zip(texs.iter()) { + let resp = w::activity_row( + ui, + &name, + &short_npub(&npub), + hue, + "", + false, + false, + tex.as_ref(), + ); if resp.clicked() { self.recipient = Some(Recipient { name, npub, hue }); self.stage = Stage::Amount; @@ -412,7 +448,7 @@ impl SendFlow { if let Some(c) = s.store.contact(hex) { (display_name(&c), c.hue as usize) } else { - let hue = usize::from_str_radix(&hex[..2], 16).unwrap_or(0) % 7; + let hue = data::hue_of(hex); ( nip05 .clone() @@ -436,7 +472,12 @@ impl SendFlow { self.stage = if preset { Stage::Review } else { Stage::Amount }; } - fn amount_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool { + fn amount_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + avatars: &mut AvatarTextures, + ) -> bool { let t = theme::tokens(); if self.back_header(ui, "Amount") { self.stage = Stage::Recipient; @@ -452,9 +493,10 @@ impl SendFlow { t.text, ); let chip_w = 28.0 + 8.0 + name_galley.size().x; + let chip_tex = tex_for(avatars, ui.ctx(), wallet, &recipient.name); ui.horizontal(|ui| { ui.add_space(((ui.available_width() - chip_w) / 2.0).max(0.0)); - w::avatar(ui, &recipient.name, 28.0, recipient.hue); + w::avatar_any(ui, &recipient.name, 28.0, recipient.hue, chip_tex.as_ref()); ui.add_space(8.0); ui.label( RichText::new(name_label) @@ -546,7 +588,12 @@ impl SendFlow { false } - fn review_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool { + fn review_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + avatars: &mut AvatarTextures, + ) -> bool { let t = theme::tokens(); if self.back_header(ui, "Review") { self.stage = Stage::Amount; @@ -554,6 +601,7 @@ impl SendFlow { } let recipient = self.recipient.clone().unwrap(); let amount = self.amount.clone(); + let hero_tex = tex_for(avatars, ui.ctx(), wallet, &recipient.name); w::card(ui, |ui| { ui.set_min_width(ui.available_width()); @@ -567,7 +615,7 @@ impl SendFlow { let row_w = 36.0 + 8.0 + galley.size().x; ui.horizontal(|ui| { ui.add_space(((ui.available_width() - row_w) / 2.0).max(0.0)); - w::avatar(ui, &recipient.name, 36.0, recipient.hue); + w::avatar_any(ui, &recipient.name, 36.0, recipient.hue, hero_tex.as_ref()); ui.add_space(8.0); ui.label( RichText::new(label) diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 7d6865d4..6abdc2a7 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -30,11 +30,12 @@ pub fn amount_str(atomic: u64) -> String { /// Draw a colored avatar puck with the contact initial. pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response { let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); - let bg = theme::avatar_color(hue); + let (bg, ink) = theme::avatar_pair(hue); ui.painter().circle_filled(rect.center(), size / 2.0, bg); + // First letter of the name — never the @ prefix or other decoration. let initial = name .chars() - .next() + .find(|c| c.is_alphanumeric()) .map(|c| c.to_uppercase().to_string()) .unwrap_or_else(|| "?".to_string()); ui.painter().text( @@ -42,11 +43,36 @@ pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response { egui::Align2::CENTER_CENTER, initial, FontId::new(size * 0.42, fonts::bold()), - theme::ink_for(bg), + ink, ); resp } +/// A custom-picture avatar: the texture drawn in a circle. +pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, size: f32) -> Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + let rounding = eframe::epaint::CornerRadius::same((size / 2.0) as u8); + egui::Image::new(tex) + .corner_radius(rounding) + .fit_to_exact_size(Vec2::splat(size)) + .paint_at(ui, rect); + resp +} + +/// Picture avatar when a texture exists, letter avatar otherwise. +pub fn avatar_any( + ui: &mut Ui, + name: &str, + size: f32, + hue: usize, + tex: Option<&egui::TextureHandle>, +) -> Response { + match tex { + Some(t) => avatar_tex(ui, t, size), + None => avatar(ui, name, size, hue), + } +} + /// Draw a balance/amount: big bold number + smaller ツ mark, tight. /// Geist (sans) per the design; mono is reserved for kernel/block ids. pub fn amount_text(ui: &mut Ui, value: &str, size: f32) { @@ -173,6 +199,28 @@ pub fn big_action_on_card(ui: &mut Ui, label: &str) -> Response { resp } +/// Like [`big_action_on_card`] with an explicit label ink (danger actions). +pub fn big_action_on_card_ink(ui: &mut Ui, label: &str, ink: Color32) -> Response { + let t = theme::tokens(); + let desired = Vec2::new(ui.available_width(), 44.0); + let (rect, resp) = ui.allocate_exact_size(desired, Sense::click()); + ui.painter().rect( + rect, + CornerRadius::same(14), + Color32::TRANSPARENT, + Stroke::new(1.5, t.line), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::new(15.0, fonts::semibold()), + ink, + ); + resp +} + /// A pill/chip; returns the click response. `active` paints it inverted. pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response { let t = theme::tokens(); @@ -317,6 +365,7 @@ pub fn activity_row( amount: &str, incoming: bool, system: bool, + tex: Option<&egui::TextureHandle>, ) -> Response { let t = theme::tokens(); let row_h = 60.0; @@ -345,7 +394,7 @@ pub fn activity_row( t.text, ); } else { - avatar(ui, title, 40.0, hue); + avatar_any(ui, title, 40.0, hue, tex); } ui.add_space(12.0); ui.vertical(|ui| { diff --git a/src/nostr/avatar.rs b/src/nostr/avatar.rs new file mode 100644 index 00000000..ccf4afa0 --- /dev/null +++ b/src/nostr/avatar.rs @@ -0,0 +1,252 @@ +// Copyright 2026 The Goblin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Client-side avatar handling: local preprocessing of a picked picture +//! (mirrors the server pipeline so uploads over Tor stay small and previews +//! are instant — the server still re-validates everything), plus a small +//! disk cache of fetched avatars keyed by username. + +use image::codecs::png::PngEncoder; +use image::metadata::Orientation; +use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::Cursor; +use std::path::PathBuf; + +/// Output dimensions (square), matching the server. +pub const SIZE: u32 = 256; +/// Raw picked files larger than this are rejected before decoding. +const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; + +/// Identify the image format from magic bytes alone (PNG/JPEG/WebP). +fn sniff(raw: &[u8]) -> Option { + if raw.len() >= 8 && raw.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + return Some(ImageFormat::Png); + } + if raw.len() >= 3 && raw.starts_with(&[0xFF, 0xD8, 0xFF]) { + return Some(ImageFormat::Jpeg); + } + if raw.len() >= 12 && &raw[0..4] == b"RIFF" && &raw[8..12] == b"WEBP" { + return Some(ImageFormat::WebP); + } + None +} + +/// Read a picked picture file and normalize it to the canonical 256×256 +/// PNG (EXIF orientation applied, every byte of metadata destroyed). +pub fn process_avatar_file(path: &str) -> Result, String> { + let meta = std::fs::metadata(path).map_err(|_| "Couldn't read that file".to_string())?; + if meta.len() > MAX_FILE_BYTES { + return Err("That picture is too large (10 MB max)".to_string()); + } + let raw = std::fs::read(path).map_err(|_| "Couldn't read that file".to_string())?; + process_avatar_bytes(&raw) +} + +/// Normalize raw image bytes to the canonical avatar PNG. +pub fn process_avatar_bytes(raw: &[u8]) -> Result, String> { + let err = || "That file doesn't look like a usable picture".to_string(); + let format = sniff(raw).ok_or_else(err)?; + let mut reader = ImageReader::with_format(Cursor::new(raw), format); + let mut limits = Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(128 * 1024 * 1024); + reader.limits(limits); + let mut decoder = reader.into_decoder().map_err(|_| err())?; + let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms); + let mut img = DynamicImage::from_decoder(decoder).map_err(|_| err())?; + img.apply_orientation(orientation); + let (w, h) = (img.width(), img.height()); + if w == 0 || h == 0 { + return Err(err()); + } + let side = w.min(h); + let img = img.crop_imm((w - side) / 2, (h - side) / 2, side, side); + let img = img.resize_exact(SIZE, SIZE, image::imageops::FilterType::Lanczos3); + let rgba = img.to_rgba8(); + let mut out = Vec::new(); + rgba.write_with_encoder(PngEncoder::new(&mut out)) + .map_err(|_| err())?; + Ok(out) +} + +/// One cached profile probe. +#[derive(Serialize, Deserialize, Clone)] +pub struct CacheEntry { + /// Avatar content hash; `None` records a confirmed has-no-avatar. + pub hash: Option, + /// When the server was last asked, unix seconds. + pub checked_at: i64, +} + +/// Disk cache of fetched avatars: `/.png` files plus an index +/// mapping names to hashes with probe timestamps (negative entries too). +pub struct AvatarCache { + dir: PathBuf, + index: HashMap, +} + +const PRESENT_TTL_SECS: i64 = 24 * 3600; +const ABSENT_TTL_SECS: i64 = 6 * 3600; + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +impl AvatarCache { + /// Open (or create) the cache at the given directory. + pub fn new(dir: PathBuf) -> Self { + let _ = std::fs::create_dir_all(&dir); + let index = std::fs::read(dir.join("index.json")) + .ok() + .and_then(|raw| serde_json::from_slice(&raw).ok()) + .unwrap_or_default(); + Self { dir, index } + } + + fn save_index(&self) { + if let Ok(raw) = serde_json::to_vec(&self.index) { + let _ = std::fs::write(self.dir.join("index.json"), raw); + } + } + + /// Cached avatar bytes for a name, if a fresh positive entry exists. + pub fn cached(&self, name: &str) -> Option<(String, Vec)> { + let entry = self.index.get(name)?; + let hash = entry.hash.clone()?; + let bytes = std::fs::read(self.dir.join(format!("{hash}.png"))).ok()?; + Some((hash, bytes)) + } + + /// Whether the entry for a name is missing or past its TTL. + pub fn stale(&self, name: &str) -> bool { + match self.index.get(name) { + None => true, + Some(e) => { + let ttl = if e.hash.is_some() { + PRESENT_TTL_SECS + } else { + ABSENT_TTL_SECS + }; + unix_now() - e.checked_at > ttl + } + } + } + + /// Record a fetched avatar. + pub fn store(&mut self, name: &str, hash: &str, png: &[u8]) { + let _ = std::fs::write(self.dir.join(format!("{hash}.png")), png); + self.index.insert( + name.to_string(), + CacheEntry { + hash: Some(hash.to_string()), + checked_at: unix_now(), + }, + ); + self.save_index(); + } + + /// Record a confirmed has-no-avatar probe. + pub fn mark_absent(&mut self, name: &str) { + self.index.insert( + name.to_string(), + CacheEntry { + hash: None, + checked_at: unix_now(), + }, + ); + self.save_index(); + } + + /// Forget a name (released, rotated away, or replaced). + pub fn remove(&mut self, name: &str) { + if let Some(CacheEntry { + hash: Some(hash), .. + }) = self.index.remove(name) + { + // Unlink only when no other name shares the file. + let shared = self + .index + .values() + .any(|e| e.hash.as_deref() == Some(hash.as_str())); + if !shared { + let _ = std::fs::remove_file(self.dir.join(format!("{hash}.png"))); + } + } + self.save_index(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use image::RgbaImage; + + fn png_bytes(w: u32, h: u32) -> Vec { + let img = RgbaImage::from_fn(w, h, |x, y| { + image::Rgba([(x % 256) as u8, (y % 256) as u8, 7, 255]) + }); + let mut out = Vec::new(); + image::DynamicImage::ImageRgba8(img) + .write_with_encoder(PngEncoder::new(&mut out)) + .unwrap(); + out + } + + #[test] + fn processes_to_canonical_png() { + let out = process_avatar_bytes(&png_bytes(500, 300)).unwrap(); + assert!(out.starts_with(&[0x89, b'P', b'N', b'G'])); + let img = image::load_from_memory(&out).unwrap(); + assert_eq!((img.width(), img.height()), (SIZE, SIZE)); + } + + #[test] + fn rejects_non_images() { + assert!(process_avatar_bytes(b"").is_err()); + assert!(process_avatar_bytes(b"GIF89a....").is_err()); + assert!(process_avatar_bytes(&[]).is_err()); + } + + #[test] + fn cache_round_trip_and_remove() { + let dir = std::env::temp_dir().join(format!("goblin-avatar-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut cache = AvatarCache::new(dir.clone()); + assert!(cache.stale("ada")); + cache.store("ada", "ab12", b"pngbytes"); + assert!(!cache.stale("ada")); + let (hash, bytes) = cache.cached("ada").unwrap(); + assert_eq!(hash, "ab12"); + assert_eq!(bytes, b"pngbytes"); + // Reload from disk. + let cache2 = AvatarCache::new(dir.clone()); + assert!(cache2.cached("ada").is_some()); + // Negative entries. + let mut cache = cache2; + cache.mark_absent("bob"); + assert!(!cache.stale("bob")); + assert!(cache.cached("bob").is_none()); + // Removal unlinks unshared files. + cache.remove("ada"); + assert!(cache.cached("ada").is_none()); + assert!(!dir.join("ab12.png").exists()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 401b1ae4..55f9e13c 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -39,4 +39,5 @@ pub use ingest::*; mod client; pub use client::{NostrService, send_phase}; +pub mod avatar; pub mod nip05; diff --git a/src/nostr/nip05.rs b/src/nostr/nip05.rs index 1f53169b..1be829c0 100644 --- a/src/nostr/nip05.rs +++ b/src/nostr/nip05.rs @@ -230,19 +230,105 @@ pub async fn transfer( } /// Release a registered name (NIP-98 authed by the owner). -pub async fn unregister(server: &str, name: &str, keys: &Keys) -> bool { +pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), String> { let server = server.trim_end_matches('/'); let url = format!("{}/api/v1/register/{}", server, urlencode(name)); let Some(auth) = nip98_auth(keys, &url, "DELETE", None) else { - return false; + return Err("couldn't sign the request".to_string()); }; let headers = vec![("Authorization".to_string(), auth)]; match Tor::http_request("DELETE", url, None, headers).await { - Some(resp) => resp.contains("\"released\":true"), - None => false, + Some(resp) if resp.contains("\"released\":true") => Ok(()), + Some(resp) => Err(serde_json::from_str::(&resp) + .ok() + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) + .unwrap_or_else(|| "server refused the release".to_string())), + None => Err("network unreachable".to_string()), } } +/// Upload a processed avatar PNG for an owned name. Returns the content +/// hash on success. NIP-98 payload hashing makes the request replay-proof. +pub async fn upload_avatar( + server: &str, + name: &str, + keys: &Keys, + png: Vec, +) -> Result { + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/avatar/{}", server, urlencode(name)); + let Some(auth) = nip98_auth(keys, &url, "POST", Some(&png)) else { + return Err("couldn't sign the request".to_string()); + }; + let headers = vec![ + ("Authorization".to_string(), auth), + ( + "Content-Type".to_string(), + "application/octet-stream".to_string(), + ), + ]; + match Tor::http_request_bytes("POST", url, Some(png), headers).await { + Some((201, raw)) => serde_json::from_slice::(&raw) + .ok() + .and_then(|v| v.get("avatar").and_then(|h| h.as_str()).map(String::from)) + .ok_or_else(|| "unexpected server response".to_string()), + Some((429, _)) => Err("Avatar limit reached — try again tomorrow".to_string()), + Some((413, _)) => Err("Image too large".to_string()), + Some((422, _)) => Err("That file doesn't look like a usable image".to_string()), + Some((code, raw)) => Err(serde_json::from_slice::(&raw) + .ok() + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) + .unwrap_or_else(|| format!("server error ({code})"))), + None => Err("network unreachable".to_string()), + } +} + +/// Remove the avatar for an owned name. +pub async fn delete_avatar(server: &str, name: &str, keys: &Keys) -> Result<(), String> { + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/avatar/{}", server, urlencode(name)); + let Some(auth) = nip98_auth(keys, &url, "DELETE", None) else { + return Err("couldn't sign the request".to_string()); + }; + let headers = vec![("Authorization".to_string(), auth)]; + match Tor::http_request_bytes("DELETE", url, None, headers).await { + Some((200, _)) => Ok(()), + Some((code, _)) => Err(format!("server error ({code})")), + None => Err("network unreachable".to_string()), + } +} + +/// Public profile probe: `None` = network failure, `Some(None)` = name has +/// no avatar (or no such name), `Some(Some(hash))` = avatar content hash. +pub async fn fetch_profile(server: &str, name: &str) -> Option> { + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/profile/{}", server, urlencode(name)); + let (code, raw) = Tor::http_request_bytes("GET", url, None, vec![]).await?; + if code == 404 { + return Some(None); + } + if code != 200 { + return None; + } + let v: serde_json::Value = serde_json::from_slice(&raw).ok()?; + Some(v.get("avatar").and_then(|h| h.as_str()).map(String::from)) +} + +/// Download a processed avatar by content hash. Verifies size and PNG +/// magic before returning — a misbehaving server can't feed the UI junk. +pub async fn fetch_avatar(server: &str, hash: &str) -> Option> { + if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/avatar/{}.png", server, hash); + let (code, raw) = Tor::http_request_bytes("GET", url, None, vec![]).await?; + if code != 200 || raw.len() > 1_048_576 || !raw.starts_with(&[0x89, b'P', b'N', b'G']) { + return None; + } + Some(raw) +} + /// Minimal percent-encoding for name path/query segments. fn urlencode(s: &str) -> String { s.chars() diff --git a/src/tor/tor.rs b/src/tor/tor.rs index 11f7a5bc..c380023a 100644 --- a/src/tor/tor.rs +++ b/src/tor/tor.rs @@ -411,6 +411,20 @@ impl Tor { body: Option, headers: Vec<(String, String)>, ) -> Option { + Self::http_request_bytes(method, url, body.map(|b| b.into_bytes()), headers) + .await + .map(|(_, raw)| String::from_utf8_lossy(&raw).to_string()) + } + + /// Bytes-level variant with the response status code — avatar uploads + /// and downloads need binary bodies, and a 404/413 must be + /// distinguishable from image bytes. + pub async fn http_request_bytes( + method: &str, + url: String, + body: Option>, + headers: Vec<(String, String)>, + ) -> Option<(u16, Vec)> { for attempt in 0..5 { if attempt > 0 { tokio::time::sleep(Duration::from_secs(1 << (attempt - 1))).await; @@ -427,9 +441,9 @@ impl Tor { async fn http_request_once( method: &str, url: String, - body: Option, + body: Option>, headers: Vec<(String, String)>, - ) -> Option { + ) -> Option<(u16, Vec)> { // Bind once to avoid a TOCTOU panic if Tor restarts mid-request. let Some((client_cfg, _)) = Self::client_config() else { error!("Tor: client not launched"); @@ -463,13 +477,16 @@ impl Tor { } }; match http.request(req).await { - Ok(r) => match hyper_tor::body::to_bytes(r).await { - Ok(raw) => Some(String::from_utf8_lossy(&raw).to_string()), - Err(e) => { - error!("Tor: http response parse error: {}", e); - None + Ok(r) => { + let status = r.status().as_u16(); + match hyper_tor::body::to_bytes(r.into_body()).await { + Ok(raw) => Some((status, raw.to_vec())), + Err(e) => { + error!("Tor: http response parse error: {}", e); + None + } } - }, + } Err(e) => { error!("Tor: http request failed: {}", e); None diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index ff564ba5..7fc8ef4c 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -486,22 +486,19 @@ impl Wallet { let (mut new_identity, new_keys) = NostrIdentity::create_random(&password) .map_err(|e| format!("key generation failed: {e}"))?; - // Move the username first; abort the rotation if that fails so the - // user never ends up keyless-but-named or named-but-keyless. + // Release the username first (the server also deletes its avatar); + // abort the rotation if that fails so the user never ends up with a + // burned key still welded to a public name. After rotation the name + // is up for grabs — by the new key or anyone else. if let Some(nip05) = old.nip05.clone() { let name = nip05.split('@').next().unwrap_or_default().to_string(); let server = { svc.config.read().nip05_server() }; - let new_hex = new_keys.public_key().to_hex(); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_err(|e| e.to_string())?; - rt.block_on(async { - crate::nostr::nip05::transfer(&server, &name, &old_keys, &new_hex).await - }) - .map_err(|e| format!("Couldn't move @{name}: {e}"))?; - new_identity.nip05 = Some(nip05); - new_identity.anonymous = false; + rt.block_on(async { crate::nostr::nip05::unregister(&server, &name, &old_keys).await }) + .map_err(|e| format!("Couldn't release @{name}: {e} — rotation cancelled"))?; } new_identity.prev_npubs = { let mut v = old.prev_npubs.clone(); diff --git a/tests/nostr_e2e.rs b/tests/nostr_e2e.rs index 1e4c0496..e798a886 100644 --- a/tests/nostr_e2e.rs +++ b/tests/nostr_e2e.rs @@ -197,3 +197,234 @@ fn protocol_parse_pubkey(body: &str, name: &str) -> Option { let doc: serde_json::Value = serde_json::from_str(body).ok()?; doc.get("names")?.get(name)?.as_str().map(|s| s.to_string()) } + +/// Live avatar pipeline e2e against goblin.st: register → upload a processed +/// PNG (NIP-98 by the owner) → profile shows the hash → GET serves a 256px +/// PNG with the hardened headers → 6th change is rate-limited → release +/// purges both the name and its avatar. +/// Run: cargo test --test nostr_e2e avatar -- --ignored --nocapture +#[tokio::test] +#[ignore] +async fn avatar_upload_roundtrip() { + use base64::Engine; + use sha2::{Digest, Sha256}; + use std::process::Command; + + let server = "https://goblin.st"; + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let name = format!("a{}", &pubkey[..8]); + + let nip98 = |url: &str, method: &str, body: &[u8]| -> String { + let mut b = EventBuilder::new(Kind::HttpAuth, "") + .tag(Tag::custom(TagKind::custom("u"), [url.to_string()])) + .tag(Tag::custom(TagKind::custom("method"), [method.to_string()])); + if !body.is_empty() { + b = b.tag(Tag::custom( + TagKind::custom("payload"), + [hex::encode(Sha256::digest(body))], + )); + } + let ev = b.sign_with_keys(&keys).unwrap(); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(ev.as_json()) + ) + }; + + // Register the name first. + let reg_url = format!("{server}/api/v1/register"); + let reg_body = serde_json::json!({ "name": name, "pubkey": pubkey }).to_string(); + let out = Command::new("curl") + .args([ + "-s", + "-X", + "POST", + ®_url, + "-H", + &format!( + "Authorization: {}", + nip98(®_url, "POST", reg_body.as_bytes()) + ), + "-H", + "Content-Type: application/json", + "-d", + ®_body, + ]) + .output() + .expect("curl register"); + assert!( + String::from_utf8_lossy(&out.stdout).contains("\"nip05\""), + "register failed: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // Build a real PNG via the client pipeline (also strips metadata). + let raw = { + use ::image::{ImageEncoder, RgbaImage}; + let img = RgbaImage::from_fn(640, 480, |x, y| { + ::image::Rgba([(x % 256) as u8, (y % 256) as u8, 90, 255]) + }); + let mut v = Vec::new(); + ::image::DynamicImage::ImageRgba8(img) + .write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v)) + .unwrap(); + v + }; + let png = grim::nostr::avatar::process_avatar_bytes(&raw).expect("process"); + let png_path = std::env::temp_dir().join(format!("{name}.png")); + std::fs::write(&png_path, &png).unwrap(); + let av_url = format!("{server}/api/v1/avatar/{name}"); + + // Upload (raw bytes; payload hash over the PNG). + let out = Command::new("curl") + .args([ + "-s", + "-X", + "POST", + &av_url, + "-H", + &format!("Authorization: {}", nip98(&av_url, "POST", &png)), + "-H", + "Content-Type: application/octet-stream", + "--data-binary", + &format!("@{}", png_path.display()), + ]) + .output() + .expect("curl upload"); + let resp = String::from_utf8_lossy(&out.stdout); + println!("upload: {resp}"); + let hash = serde_json::from_str::(&resp) + .ok() + .and_then(|v| v.get("avatar").and_then(|h| h.as_str()).map(String::from)) + .expect("upload should return a hash"); + + // Profile exposes the hash. + let prof = Command::new("curl") + .args(["-s", &format!("{server}/api/v1/profile/{name}")]) + .output() + .unwrap(); + assert!( + String::from_utf8_lossy(&prof.stdout).contains(&hash), + "profile should carry the avatar hash" + ); + + // GET serves a 256px PNG with hardened headers. + let head = Command::new("curl") + .args(["-sI", &format!("{server}/api/v1/avatar/{hash}.png")]) + .output() + .unwrap(); + let head = String::from_utf8_lossy(&head.stdout).to_lowercase(); + assert!(head.contains("content-type: image/png"), "headers: {head}"); + assert!(head.contains("nosniff"), "missing nosniff: {head}"); + assert!( + head.contains("immutable"), + "missing immutable cache: {head}" + ); + let got = Command::new("curl") + .args(["-s", &format!("{server}/api/v1/avatar/{hash}.png")]) + .output() + .unwrap(); + assert!(got.stdout.starts_with(&[0x89, b'P', b'N', b'G'])); + let served = ::image::load_from_memory(&got.stdout).expect("served bytes decode"); + assert_eq!((served.width(), served.height()), (256, 256)); + + // Daily limit: 4 more changes succeed (1 done = 5 total), the 6th is 429. + for i in 0..4 { + // Vary the pixels so each upload is a distinct hash. + let raw = { + use ::image::{ImageEncoder, RgbaImage}; + let img = RgbaImage::from_pixel(64, 64, ::image::Rgba([i as u8 * 40, 10, 10, 255])); + let mut v = Vec::new(); + ::image::DynamicImage::ImageRgba8(img) + .write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v)) + .unwrap(); + v + }; + let png = grim::nostr::avatar::process_avatar_bytes(&raw).unwrap(); + std::fs::write(&png_path, &png).unwrap(); + let out = Command::new("curl") + .args([ + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-X", + "POST", + &av_url, + "-H", + &format!("Authorization: {}", nip98(&av_url, "POST", &png)), + "--data-binary", + &format!("@{}", png_path.display()), + ]) + .output() + .unwrap(); + println!("change {}: {}", i + 2, String::from_utf8_lossy(&out.stdout)); + } + // 6th change → 429. + let png = grim::nostr::avatar::process_avatar_bytes(&{ + use ::image::{ImageEncoder, RgbaImage}; + let img = RgbaImage::from_pixel(48, 48, ::image::Rgba([200, 200, 0, 255])); + let mut v = Vec::new(); + ::image::DynamicImage::ImageRgba8(img) + .write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v)) + .unwrap(); + v + }) + .unwrap(); + std::fs::write(&png_path, &png).unwrap(); + let out = Command::new("curl") + .args([ + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-X", + "POST", + &av_url, + "-H", + &format!("Authorization: {}", nip98(&av_url, "POST", &png)), + "--data-binary", + &format!("@{}", png_path.display()), + ]) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + "429", + "6th avatar change in 24h must be rate-limited" + ); + + // Release the name → avatar purged. + let del_url = format!("{server}/api/v1/register/{name}"); + let _ = Command::new("curl") + .args([ + "-s", + "-X", + "DELETE", + &del_url, + "-H", + &format!("Authorization: {}", nip98(&del_url, "DELETE", &[])), + ]) + .output(); + let after = Command::new("curl") + .args([ + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + &format!("{server}/api/v1/profile/{name}"), + ]) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&after.stdout), + "404", + "profile should 404 after release" + ); + let _ = std::fs::remove_file(&png_path); + println!("✓ avatar upload/serve/limit/release-purge verified on {server}"); +}