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:
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -39,4 +39,5 @@ pub use ingest::*;
|
||||
mod client;
|
||||
pub use client::{NostrService, send_phase};
|
||||
|
||||
pub mod avatar;
|
||||
pub mod nip05;
|
||||
|
||||
+90
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
®_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::<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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user