notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit a714bef690d8bf6906646579e23f2e1dd1914721
parent 4e3fcad7091d8857e98a32008ce628875c1c010e
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 15 Jul 2025 08:24:46 -0700

ui/profile: fix dubious profile editing

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mcrates/enostr/src/lib.rs | 2+-
Mcrates/enostr/src/profile.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/notedeck_columns/src/lib.rs | 1-
Mcrates/notedeck_columns/src/nav.rs | 20+++++++++++++++-----
Mcrates/notedeck_columns/src/profile.rs | 12++++++++----
Dcrates/notedeck_columns/src/profile_state.rs | 79-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/test_data.rs | 6+++---
Mcrates/notedeck_columns/src/ui/profile/edit.rs | 122+++++++++++++++++++++++++++----------------------------------------------------
Mcrates/notedeck_columns/src/view_state.rs | 2+-
9 files changed, 148 insertions(+), 188 deletions(-)

diff --git a/crates/enostr/src/lib.rs b/crates/enostr/src/lib.rs @@ -14,7 +14,7 @@ pub use filter::Filter; pub use keypair::{FilledKeypair, FullKeypair, Keypair, KeypairUnowned, SerializableKeypair}; pub use nostr::SecretKey; pub use note::{Note, NoteId}; -pub use profile::Profile; +pub use profile::ProfileState; pub use pubkey::{Pubkey, PubkeyRef}; pub use relay::message::{RelayEvent, RelayMessage}; pub use relay::pool::{PoolEvent, PoolRelay, RelayPool}; diff --git a/crates/enostr/src/profile.rs b/crates/enostr/src/profile.rs @@ -1,38 +1,104 @@ -use serde_json::Value; +use serde_json::{Map, Value}; -#[derive(Debug, Clone)] -pub struct Profile(Value); +#[derive(Debug, Clone, Default)] +pub struct ProfileState(Value); -impl Profile { - pub fn new(value: Value) -> Profile { - Profile(value) +impl ProfileState { + pub fn new(value: Map<String, Value>) -> Self { + Self(Value::Object(value)) } + pub fn get_str(&self, name: &str) -> Option<&str> { + self.0.get(name).and_then(|v| v.as_str()) + } + + pub fn values_mut(&mut self) -> &mut Map<String, Value> { + self.0.as_object_mut().unwrap() + } + + /// Insert or overwrite an existing value with a string + pub fn str_mut(&mut self, name: &str) -> &mut String { + let val = self + .values_mut() + .entry(name) + .or_insert(Value::String("".to_string())); + + // if its not a string, make it one. this will overrwrite + // the old value, so be careful + if !val.is_string() { + *val = Value::String("".to_string()); + } + + match val { + Value::String(s) => s, + // SAFETY: we replace it above, so its impossible to be something + // other than a string + _ => panic!("impossible"), + } + } + + pub fn value(&self) -> &Value { + &self.0 + } + + pub fn to_json(&self) -> String { + // SAFETY: serializing a value should be irrefutable + serde_json::to_string(self.value()).unwrap() + } + + #[inline] pub fn name(&self) -> Option<&str> { - self.0["name"].as_str() + self.get_str("name") } + #[inline] + pub fn banner(&self) -> Option<&str> { + self.get_str("name") + } + + #[inline] pub fn display_name(&self) -> Option<&str> { - self.0["display_name"].as_str() + self.get_str("display_name") } + #[inline] pub fn lud06(&self) -> Option<&str> { - self.0["lud06"].as_str() + self.get_str("lud06") } + #[inline] + pub fn nip05(&self) -> Option<&str> { + self.get_str("nip05") + } + + #[inline] pub fn lud16(&self) -> Option<&str> { - self.0["lud16"].as_str() + self.get_str("lud16") } + #[inline] pub fn about(&self) -> Option<&str> { - self.0["about"].as_str() + self.get_str("about") } + #[inline] pub fn picture(&self) -> Option<&str> { - self.0["picture"].as_str() + self.get_str("picture") } + #[inline] pub fn website(&self) -> Option<&str> { - self.0["website"].as_str() + self.get_str("website") + } + + pub fn from_note_contents(contents: &str) -> Self { + let json = serde_json::from_str(contents); + let data = if let Ok(Value::Object(data)) = json { + data + } else { + Map::new() + }; + + Self::new(data) } } diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs @@ -19,7 +19,6 @@ mod multi_subscriber; mod nav; mod post; mod profile; -mod profile_state; mod route; mod search; mod subscriptions; diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -5,7 +5,6 @@ use crate::{ deck_state::DeckState, decks::{Deck, DecksAction, DecksCache}, profile::{ProfileAction, SaveProfileChanges}, - profile_state::ProfileState, route::{Route, Router, SingletonRouter}, timeline::{ route::{render_thread_route, render_timeline_route}, @@ -28,9 +27,11 @@ use crate::{ }; use egui_nav::{Nav, NavAction, NavResponse, NavUiType, Percent, PopupResponse, PopupSheet}; -use nostrdb::Transaction; +use enostr::ProfileState; +use nostrdb::{Filter, Transaction}; use notedeck::{ - get_current_default_msats, get_current_wallet, AppContext, NoteAction, NoteContext, RelayAction, + get_current_default_msats, get_current_wallet, ui::is_narrow, AppContext, NoteAction, + NoteContext, RelayAction, }; use tracing::error; @@ -674,8 +675,17 @@ fn render_nav_body( .entry(*kp.pubkey) .or_insert_with(|| { let txn = Transaction::new(ctx.ndb).expect("txn"); - if let Ok(record) = ctx.ndb.get_profile_by_pubkey(&txn, kp.pubkey.bytes()) { - ProfileState::from_profile(&record) + let filter = Filter::new_with_capacity(1) + .kinds([0]) + .authors([kp.pubkey.bytes()]) + .build(); + + let Ok(results) = ctx.ndb.query(&txn, &[filter], 1) else { + return ProfileState::default(); + }; + + if let Some(result) = results.first() { + ProfileState::from_note_contents(result.note.content()) } else { ProfileState::default() } diff --git a/crates/notedeck_columns/src/profile.rs b/crates/notedeck_columns/src/profile.rs @@ -1,12 +1,12 @@ use std::collections::HashMap; -use enostr::{FilledKeypair, FullKeypair, Pubkey, RelayPool}; +use enostr::{FilledKeypair, FullKeypair, ProfileState, Pubkey, RelayPool}; use nostrdb::{Ndb, Note, NoteBuildOptions, NoteBuilder, Transaction}; use notedeck::{Accounts, ContactState}; use tracing::info; -use crate::{nav::RouterAction, profile_state::ProfileState, route::Route}; +use crate::{nav::RouterAction, route::Route}; pub struct SaveProfileChanges { pub kp: FullKeypair, @@ -149,13 +149,17 @@ fn send_kind_3_event(ndb: &Ndb, pool: &mut RelayPool, accounts: &Accounts, actio let contact_note = match ndb.get_note_by_key(&txn, *note_key).ok() { Some(n) => n, None => { - tracing::error!("Somehow we are in state ContactState::Received but the contact note key doesn't exist"); + tracing::error!( + "Somehow we are in state ContactState::Received but the contact note key doesn't exist" + ); return; } }; if contact_note.kind() != 3 { - tracing::error!("Something very wrong just occured. The key for the supposed contact note yielded a note which was not a contact..."); + tracing::error!( + "Something very wrong just occured. The key for the supposed contact note yielded a note which was not a contact..." + ); return; } diff --git a/crates/notedeck_columns/src/profile_state.rs b/crates/notedeck_columns/src/profile_state.rs @@ -1,79 +0,0 @@ -use nostrdb::{NdbProfile, ProfileRecord}; - -#[derive(Default, Debug)] -pub struct ProfileState { - pub display_name: String, - pub name: String, - pub picture: String, - pub banner: String, - pub about: String, - pub website: String, - pub lud16: String, - pub nip05: String, -} - -impl ProfileState { - pub fn from_profile(record: &ProfileRecord<'_>) -> Self { - let display_name = get_item(record, |p| p.display_name()); - let username = get_item(record, |p| p.name()); - let profile_picture = get_item(record, |p| p.picture()); - let cover_image = get_item(record, |p| p.banner()); - let about = get_item(record, |p| p.about()); - let website = get_item(record, |p| p.website()); - let lud16 = get_item(record, |p| p.lud16()); - let nip05 = get_item(record, |p| p.nip05()); - - Self { - display_name, - name: username, - picture: profile_picture, - banner: cover_image, - about, - website, - lud16, - nip05, - } - } - - pub fn to_json(&self) -> String { - let mut fields = Vec::new(); - - if !self.display_name.is_empty() { - fields.push(format!(r#""display_name":"{}""#, self.display_name)); - } - if !self.name.is_empty() { - fields.push(format!(r#""name":"{}""#, self.name)); - } - if !self.picture.is_empty() { - fields.push(format!(r#""picture":"{}""#, self.picture)); - } - if !self.banner.is_empty() { - fields.push(format!(r#""banner":"{}""#, self.banner)); - } - if !self.about.is_empty() { - fields.push(format!(r#""about":"{}""#, self.about)); - } - if !self.website.is_empty() { - fields.push(format!(r#""website":"{}""#, self.website)); - } - if !self.lud16.is_empty() { - fields.push(format!(r#""lud16":"{}""#, self.lud16)); - } - if !self.nip05.is_empty() { - fields.push(format!(r#""nip05":"{}""#, self.nip05)); - } - - format!("{{{}}}", fields.join(",")) - } -} - -fn get_item<'a>( - record: &ProfileRecord<'a>, - item_retriever: fn(NdbProfile<'a>) -> Option<&'a str>, -) -> String { - record - .record() - .profile() - .and_then(item_retriever) - .map_or_else(String::new, ToString::to_string) -} diff --git a/crates/notedeck_columns/src/test_data.rs b/crates/notedeck_columns/src/test_data.rs @@ -20,7 +20,7 @@ pub fn sample_pool() -> RelayPool { } // my (jb55) profile -const TEST_PROFILE_DATA: [u8; 448] = [ +const _TEST_PROFILE_DATA: [u8; 448] = [ 0x04, 0x00, 0x00, 0x00, 0x54, 0xfe, 0xff, 0xff, 0x34, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xd6, 0xd9, 0xc6, 0x65, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x66, 0x69, 0x78, 0x6d, @@ -62,8 +62,8 @@ pub fn test_pubkey() -> &'static [u8; 32] { } */ -pub fn test_profile_record() -> ProfileRecord<'static> { - ProfileRecord::new_owned(&TEST_PROFILE_DATA).unwrap() +pub fn _test_profile_record() -> ProfileRecord<'static> { + ProfileRecord::new_owned(&_TEST_PROFILE_DATA).unwrap() } /* diff --git a/crates/notedeck_columns/src/ui/profile/edit.rs b/crates/notedeck_columns/src/ui/profile/edit.rs @@ -1,7 +1,7 @@ use core::f32; -use crate::profile_state::ProfileState; use egui::{vec2, Button, CornerRadius, Layout, Margin, RichText, ScrollArea, TextEdit}; +use enostr::ProfileState; use notedeck::{profile::unwrap_profile_url, Images, NotedeckTextStyle}; use notedeck_ui::{profile::banner, ProfilePic}; @@ -19,7 +19,7 @@ impl<'a> EditProfileView<'a> { pub fn ui(&mut self, ui: &mut egui::Ui) -> bool { ScrollArea::vertical() .show(ui, |ui| { - banner(ui, Some(&self.state.banner), 188.0); + banner(ui, self.state.banner(), 188.0); let padding = 24.0; notedeck_ui::padding(padding, ui, |ui| { @@ -53,11 +53,7 @@ impl<'a> EditProfileView<'a> { pfp_rect.set_height(size); let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); - let pfp_url = unwrap_profile_url(if self.state.picture.is_empty() { - None - } else { - Some(&self.state.picture) - }); + let pfp_url = unwrap_profile_url(self.state.picture()); ui.put( pfp_rect, &mut ProfilePic::new(self.img_cache, pfp_url) @@ -67,65 +63,72 @@ impl<'a> EditProfileView<'a> { in_frame(ui, |ui| { ui.add(label("Display name")); - ui.add(singleline_textedit(&mut self.state.display_name)); + ui.add(singleline_textedit(self.state.str_mut("display_name"))); }); in_frame(ui, |ui| { ui.add(label("Username")); - ui.add(singleline_textedit(&mut self.state.name)); + ui.add(singleline_textedit(self.state.str_mut("name"))); }); in_frame(ui, |ui| { ui.add(label("Profile picture")); - ui.add(multiline_textedit(&mut self.state.picture)); + ui.add(multiline_textedit(self.state.str_mut("picture"))); }); in_frame(ui, |ui| { ui.add(label("Banner")); - ui.add(multiline_textedit(&mut self.state.banner)); + ui.add(multiline_textedit(self.state.str_mut("banner"))); }); in_frame(ui, |ui| { ui.add(label("About")); - ui.add(multiline_textedit(&mut self.state.about)); + ui.add(multiline_textedit(self.state.str_mut("about"))); }); in_frame(ui, |ui| { ui.add(label("Website")); - ui.add(singleline_textedit(&mut self.state.website)); + ui.add(singleline_textedit(self.state.str_mut("website"))); }); in_frame(ui, |ui| { ui.add(label("Lightning network address (lud16)")); - ui.add(multiline_textedit(&mut self.state.lud16)); + ui.add(multiline_textedit(self.state.str_mut("lud16"))); }); in_frame(ui, |ui| { ui.add(label("Nostr address (NIP-05 identity)")); - ui.add(singleline_textedit(&mut self.state.nip05)); - let split = &mut self.state.nip05.split('@'); - let prefix = split.next(); - let suffix = split.next(); - if let Some(prefix) = prefix { - if let Some(suffix) = suffix { - let use_domain = if let Some(f) = prefix.chars().next() { - f == '_' - } else { - false - }; - ui.colored_label( - ui.visuals().noninteractive().fg_stroke.color, - RichText::new(if use_domain { - format!("\"{}\" will be used for identification", suffix) - } else { - format!( - "\"{}\" at \"{}\" will be used for identification", - prefix, suffix - ) - }), - ); - } - } + ui.add(singleline_textedit(self.state.str_mut("nip05"))); + + let Some(nip05) = self.state.nip05() else { + return; + }; + + let mut split = nip05.split('@'); + + let Some(prefix) = split.next() else { + return; + }; + let Some(suffix) = split.next() else { + return; + }; + + let use_domain = if let Some(f) = prefix.chars().next() { + f == '_' + } else { + false + }; + ui.colored_label( + ui.visuals().noninteractive().fg_stroke.color, + RichText::new(if use_domain { + format!("\"{}\" will be used for identification", suffix) + } else { + format!( + "\"{}\" at \"{}\" will be used for identification", + prefix, suffix + ) + }), + ); }); } } @@ -165,46 +168,3 @@ fn button(text: &str, width: f32) -> egui::Button<'static> { .corner_radius(CornerRadius::same(8)) .min_size(vec2(width, 40.0)) } - -mod preview { - use notedeck::{App, AppAction}; - - use crate::{ - profile_state::ProfileState, - test_data, - ui::{Preview, PreviewConfig}, - }; - - use super::EditProfileView; - - pub struct EditProfilePreivew { - state: ProfileState, - } - - impl Default for EditProfilePreivew { - fn default() -> Self { - Self { - state: ProfileState::from_profile(&test_data::test_profile_record()), - } - } - } - - impl App for EditProfilePreivew { - fn update( - &mut self, - ctx: &mut notedeck::AppContext<'_>, - ui: &mut egui::Ui, - ) -> Option<AppAction> { - EditProfileView::new(&mut self.state, ctx.img_cache).ui(ui); - None - } - } - - impl Preview for EditProfileView<'_> { - type Prev = EditProfilePreivew; - - fn preview(_cfg: PreviewConfig) -> Self::Prev { - EditProfilePreivew::default() - } - } -} diff --git a/crates/notedeck_columns/src/view_state.rs b/crates/notedeck_columns/src/view_state.rs @@ -4,8 +4,8 @@ use enostr::Pubkey; use crate::deck_state::DeckState; use crate::login_manager::AcquireKeyState; -use crate::profile_state::ProfileState; use crate::ui::search::SearchQueryState; +use enostr::ProfileState; /// Various state for views #[derive(Default)]