1
0
forked from GRIN/grim

Build 13: hosted profile pictures, username release, claim/release UX

Identity overhaul (server changes deployed to goblin.st separately):

Avatars
- profile pictures hosted on goblin.st, tied to the username: tap the
  settings avatar → native image picker → 256px PNG uploaded over Tor
- letter avatars now use (background, ink) color pairs (8) keyed off the
  npub, and render the first ALPHANUMERIC char — never the '@'
- custom pictures shown everywhere self/contacts appear: settings card,
  home header, sidebar chip, peers strip, activity rows, send recipient
- AvatarTextures: disk cache (~/.goblin/cache/avatars) + background Tor
  fetch + egui textures loaded on the UI thread; a network/Tor failure
  is never cached as "no avatar" (would have stuck for 6h)
- nostr/avatar.rs mirrors the server's sniff→limits→orientation→crop→
  256→re-encode-PNG pipeline so uploads are small and previews instant

Username lifecycle
- rotating the key now RELEASES the username (and deletes its avatar
  server-side) instead of transferring; rotation aborts if release fails
- claim panel is one Claim button (checks then registers); registered
  state shows "Registered <name>" + a Release action behind an
  are-you-sure gate ("up for grabs the moment it's free")
- released names are immediately re-claimable (quarantine removed)

Other
- Tor::http_request_bytes: binary bodies + status code, for upload and
  avatar download (string http_request kept as a wrapper)
- settings reordered Identity-first, then Wallet
- sidebar node card is 3 lines: status / block height / host
- profile card shows the full npub when it fits, else head…tail

34 lib tests green. Live-verified on goblin.st: upload→serve (image/png,
nosniff, immutable)→5/day limit (6th 429)→release purges avatar; a real
picture for @fartmuncher22 fetched over Tor and rendered across surfaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-11 12:22:41 -04:00
parent a12f894dff
commit b1b9bd61af
15 changed files with 1513 additions and 276 deletions
+9
View File
@@ -292,6 +292,15 @@ impl PlatformCallbacks for Desktop {
None
}
fn pick_image_file(&self) -> Option<String> {
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<String> {
let file = FileDialog::new()
.set_title(t!("choose_folder"))
+5
View File
@@ -33,6 +33,11 @@ pub trait PlatformCallbacks {
fn switch_camera(&self);
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error>;
fn pick_file(&self) -> Option<String>;
/// 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<String> {
self.pick_file()
}
fn pick_folder(&self) -> Option<String>;
fn picked_file(&self) -> Option<String>;
fn request_user_attention(&self);
+48 -17
View File
@@ -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()
}
+177
View File
@@ -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<u8>),
/// 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<String, Option<egui::TextureHandle>>,
inflight: HashSet<String>,
tx: Sender<FetchResult>,
rx: Receiver<FetchResult>,
}
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<egui::ColorImage> {
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<egui::TextureHandle> {
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));
});
}
}
+12 -2
View File
@@ -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());
+539 -216
View File
@@ -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<std::sync::Mutex<Option<Result<(String, Vec<u8>), String>>>>,
/// Last upload outcome message (cleared on the next attempt).
avatar_msg: Option<String>,
}
/// 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<bool>,
result: std::sync::Arc<std::sync::Mutex<Option<ClaimMsg>>>,
message: Option<String>,
/// 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<egui::TextureHandle> {
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<Option<egui::TextureHandle>> = 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<String> = 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<String> = 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<std::sync::Mutex<Option<Result<(String, Vec<u8>), 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()
+3 -2
View File
@@ -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 {
+62 -14
View File
@@ -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<egui::TextureHandle> {
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<Option<egui::TextureHandle>> = 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)
+53 -4
View File
@@ -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| {
+252
View File
@@ -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<ImageFormat> {
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<Vec<u8>, 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<Vec<u8>, 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<String>,
/// When the server was last asked, unix seconds.
pub checked_at: i64,
}
/// Disk cache of fetched avatars: `<dir>/<hash>.png` files plus an index
/// mapping names to hashes with probe timestamps (negative entries too).
pub struct AvatarCache {
dir: PathBuf,
index: HashMap<String, CacheEntry>,
}
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<u8>)> {
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<u8> {
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"<svg onload=alert(1)></svg>").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);
}
}
+1
View File
@@ -39,4 +39,5 @@ pub use ingest::*;
mod client;
pub use client::{NostrService, send_phase};
pub mod avatar;
pub mod nip05;
+90 -4
View File
@@ -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::<serde_json::Value>(&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<u8>,
) -> Result<String, String> {
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::<serde_json::Value>(&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::<serde_json::Value>(&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<Option<String>> {
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<Vec<u8>> {
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()
+25 -8
View File
@@ -411,6 +411,20 @@ impl Tor {
body: Option<String>,
headers: Vec<(String, String)>,
) -> Option<String> {
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<Vec<u8>>,
headers: Vec<(String, String)>,
) -> Option<(u16, Vec<u8>)> {
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<String>,
body: Option<Vec<u8>>,
headers: Vec<(String, String)>,
) -> Option<String> {
) -> Option<(u16, Vec<u8>)> {
// 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
+6 -9
View File
@@ -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();
+231
View File
@@ -197,3 +197,234 @@ fn protocol_parse_pubkey(body: &str, name: &str) -> Option<String> {
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",
&reg_url,
"-H",
&format!(
"Authorization: {}",
nip98(&reg_url, "POST", reg_body.as_bytes())
),
"-H",
"Content-Type: application/json",
"-d",
&reg_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::<serde_json::Value>(&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}");
}