commit e1187c372f02d0c3c8303ba89264295df381a8f2 parent 5f21d32d9674a241a550d1cb326d8e88f0e70304 Author: William Casarin <jb55@jb55.com> Date: Sat, 4 Jan 2025 13:15:25 -0800 Merge profiling editing #625 Changelog-Added: Added profile editing Diffstat:
24 files changed, 894 insertions(+), 210 deletions(-)
diff --git a/assets/icons/edit_icon_4x_dark.png b/assets/icons/edit_icon_4x_dark.png Binary files differ. diff --git a/assets/icons/key_4x.png b/assets/icons/key_4x.png Binary files differ. diff --git a/assets/icons/links_4x.png b/assets/icons/links_4x.png Binary files differ. diff --git a/assets/icons/verified_4x.png b/assets/icons/verified_4x.png Binary files differ. diff --git a/assets/icons/zap_4x.png b/assets/icons/zap_4x.png Binary files differ. diff --git a/crates/notedeck/src/accounts.rs b/crates/notedeck/src/accounts.rs @@ -330,6 +330,14 @@ impl Accounts { None } + pub fn contains_full_kp(&self, pubkey: &enostr::Pubkey) -> bool { + if let Some(contains) = self.contains_account(pubkey.bytes()) { + contains.has_nsec + } else { + false + } + } + #[must_use = "UnknownIdAction's must be handled. Use .process_unknown_id_action()"] pub fn add_account(&mut self, account: Keypair) -> AddAccountAction { let pubkey = account.pubkey; @@ -567,6 +575,18 @@ impl Accounts { self.needs_relay_config = false; } } + + pub fn get_full<'a>(&'a self, pubkey: &[u8; 32]) -> Option<FilledKeypair<'a>> { + if let Some(contains) = self.contains_account(pubkey) { + if contains.has_nsec { + if let Some(kp) = self.get_account(contains.index) { + return kp.to_full(); + } + } + } + + None + } } fn get_selected_index(accounts: &[UserAccount], keystore: &KeyStorageType) -> Option<usize> { diff --git a/crates/notedeck/src/style.rs b/crates/notedeck/src/style.rs @@ -49,4 +49,11 @@ impl NotedeckTextStyle { pub fn get_font_id(&self, ctx: &Context) -> FontId { FontId::new(get_font_size(ctx, self), self.font_family()) } + + pub fn get_bolded_font(&self, ctx: &Context) -> FontId { + FontId::new( + get_font_size(ctx, self), + egui::FontFamily::Name(crate::NamedFontFamily::Bold.as_str().into()), + ) + } } diff --git a/crates/notedeck_chrome/src/preview.rs b/crates/notedeck_chrome/src/preview.rs @@ -3,6 +3,7 @@ use notedeck_chrome::setup::generate_native_options; use notedeck_chrome::Notedeck; use notedeck_columns::ui::configure_deck::ConfigureDeckView; use notedeck_columns::ui::edit_deck::EditDeckView; +use notedeck_columns::ui::profile::EditProfileView; use notedeck_columns::ui::{ account_login_view::AccountLoginView, PostView, Preview, PreviewApp, PreviewConfig, ProfilePic, ProfilePreview, RelayView, @@ -95,5 +96,6 @@ async fn main() { PostView, ConfigureDeckView, EditDeckView, + EditProfileView, ); } diff --git a/crates/notedeck_columns/src/colors.rs b/crates/notedeck_columns/src/colors.rs @@ -3,3 +3,4 @@ use egui::Color32; pub const ALMOST_WHITE: Color32 = Color32::from_rgb(0xFA, 0xFA, 0xFA); pub const MID_GRAY: Color32 = Color32::from_rgb(0xbd, 0xbd, 0xbd); pub const PINK: Color32 = Color32::from_rgb(0xE4, 0x5A, 0xC9); +pub const TEAL: Color32 = Color32::from_rgb(0x77, 0xDC, 0xE1); diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs @@ -23,6 +23,7 @@ mod nav; mod notes_holder; mod post; mod profile; +mod profile_state; pub mod relay_pool_manager; mod route; mod subscriptions; @@ -42,6 +43,6 @@ pub mod storage; pub use app::Damus; pub use error::Error; -pub use profile::DisplayName; +pub use profile::NostrName; pub type Result<T> = std::result::Result<T, error::Error>; diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -6,7 +6,8 @@ use crate::{ deck_state::DeckState, decks::{Deck, DecksAction, DecksCache}, notes_holder::NotesHolder, - profile::Profile, + profile::{Profile, ProfileAction, SaveProfileChanges}, + profile_state::ProfileState, relay_pool_manager::RelayPoolManager, route::Route, thread::Thread, @@ -21,6 +22,7 @@ use crate::{ configure_deck::ConfigureDeckView, edit_deck::{EditDeckResponse, EditDeckView}, note::{PostAction, PostType}, + profile::EditProfileView, support::SupportView, RelayView, View, }, @@ -39,6 +41,7 @@ pub enum RenderNavAction { RemoveColumn, PostAction(PostAction), NoteAction(NoteAction), + ProfileAction(ProfileAction), SwitchingAction(SwitchingAction), } @@ -168,6 +171,16 @@ impl RenderNavResponse { RenderNavAction::SwitchingAction(switching_action) => { switching_occured = switching_action.process(&mut app.decks_cache, ctx); } + RenderNavAction::ProfileAction(profile_action) => { + profile_action.process( + &mut app.view_state.pubkey_to_profile_state, + ctx.ndb, + ctx.pool, + get_active_columns_mut(ctx.accounts, &mut app.decks_cache) + .column_mut(col) + .router_mut(), + ); + } } } @@ -368,6 +381,35 @@ fn render_nav_body( action } + Route::EditProfile(pubkey) => { + let mut action = None; + if let Some(kp) = ctx.accounts.get_full(pubkey.bytes()) { + let state = app + .view_state + .pubkey_to_profile_state + .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) + } else { + ProfileState::default() + } + }); + if EditProfileView::new(state, ctx.img_cache).ui(ui) { + if let Some(taken_state) = + app.view_state.pubkey_to_profile_state.remove(kp.pubkey) + { + action = Some(RenderNavAction::ProfileAction(ProfileAction::SaveChanges( + SaveProfileChanges::new(kp.to_full(), taken_state), + ))) + } + } + } else { + error!("Pubkey in EditProfile route did not have an nsec attached in Accounts"); + } + action + } } } diff --git a/crates/notedeck_columns/src/profile.rs b/crates/notedeck_columns/src/profile.rs @@ -1,28 +1,43 @@ -use enostr::{Filter, Pubkey}; -use nostrdb::{FilterBuilder, Ndb, ProfileRecord, Transaction}; +use std::collections::HashMap; + +use enostr::{Filter, FullKeypair, Pubkey, RelayPool}; +use nostrdb::{ + FilterBuilder, Ndb, Note, NoteBuildOptions, NoteBuilder, ProfileRecord, Transaction, +}; use notedeck::{filter::default_limit, FilterState, MuteFun, NoteCache, NoteRef}; +use tracing::info; use crate::{ multi_subscriber::MultiSubscriber, notes_holder::NotesHolder, + profile_state::ProfileState, + route::{Route, Router}, timeline::{copy_notes_into_timeline, PubkeySource, Timeline, TimelineKind, TimelineTab}, }; -pub enum DisplayName<'a> { - One(&'a str), - - Both { - username: &'a str, - display_name: &'a str, - }, +pub struct NostrName<'a> { + pub username: Option<&'a str>, + pub display_name: Option<&'a str>, + pub nip05: Option<&'a str>, } -impl<'a> DisplayName<'a> { - pub fn username(&self) -> &'a str { - match self { - Self::One(n) => n, - Self::Both { username, .. } => username, +impl<'a> NostrName<'a> { + pub fn name(&self) -> &'a str { + if let Some(name) = self.username { + name + } else if let Some(name) = self.display_name { + name + } else { + self.nip05.unwrap_or("??") + } + } + + pub fn unknown() -> Self { + Self { + username: None, + display_name: None, + nip05: None, } } } @@ -31,19 +46,35 @@ fn is_empty(s: &str) -> bool { s.chars().all(|c| c.is_whitespace()) } -pub fn get_profile_name<'a>(record: &ProfileRecord<'a>) -> Option<DisplayName<'a>> { - let profile = record.record().profile()?; - let display_name = profile.display_name().filter(|n| !is_empty(n)); - let name = profile.name().filter(|n| !is_empty(n)); - - match (display_name, name) { - (None, None) => None, - (Some(disp), None) => Some(DisplayName::One(disp)), - (None, Some(username)) => Some(DisplayName::One(username)), - (Some(display_name), Some(username)) => Some(DisplayName::Both { - display_name, - username, - }), +pub fn get_display_name<'a>(record: Option<&ProfileRecord<'a>>) -> NostrName<'a> { + if let Some(record) = record { + if let Some(profile) = record.record().profile() { + let display_name = profile.display_name().filter(|n| !is_empty(n)); + let username = profile.name().filter(|n| !is_empty(n)); + let nip05 = if let Some(raw_nip05) = profile.nip05() { + if let Some(at_pos) = raw_nip05.find('@') { + if raw_nip05.starts_with('_') { + raw_nip05.get(at_pos + 1..) + } else { + Some(raw_nip05) + } + } else { + None + } + } else { + None + }; + + NostrName { + username, + display_name, + nip05, + } + } else { + NostrName::unknown() + } + } else { + NostrName::unknown() } } @@ -131,3 +162,62 @@ impl NotesHolder for Profile { self.multi_subscriber = Some(subscriber); } } + +pub struct SaveProfileChanges { + pub kp: FullKeypair, + pub state: ProfileState, +} + +impl SaveProfileChanges { + pub fn new(kp: FullKeypair, state: ProfileState) -> Self { + Self { kp, state } + } + pub fn to_note(&self) -> Note { + let sec = &self.kp.secret_key.to_secret_bytes(); + add_client_tag(NoteBuilder::new()) + .kind(0) + .content(&self.state.to_json()) + .options(NoteBuildOptions::default().created_at(true).sign(sec)) + .build() + .expect("should build") + } +} + +fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> { + builder + .start_tag() + .tag_str("client") + .tag_str("Damus Notedeck") +} + +pub enum ProfileAction { + Edit(FullKeypair), + SaveChanges(SaveProfileChanges), +} + +impl ProfileAction { + pub fn process( + &self, + state_map: &mut HashMap<Pubkey, ProfileState>, + ndb: &Ndb, + pool: &mut RelayPool, + router: &mut Router<Route>, + ) { + match self { + ProfileAction::Edit(kp) => { + router.route_to(Route::EditProfile(kp.pubkey)); + } + ProfileAction::SaveChanges(changes) => { + let raw_msg = format!("[\"EVENT\",{}]", changes.to_note().json().unwrap()); + + let _ = ndb.process_client_event(raw_msg.as_str()); + let _ = state_map.remove_entry(&changes.kp.pubkey); + + info!("sending {}", raw_msg); + pool.send(&enostr::ClientMessage::raw(raw_msg)); + + router.go_back(); + } + } + } +} diff --git a/crates/notedeck_columns/src/profile_state.rs b/crates/notedeck_columns/src/profile_state.rs @@ -0,0 +1,79 @@ +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/route.rs b/crates/notedeck_columns/src/route.rs @@ -16,6 +16,7 @@ pub enum Route { Relays, ComposeNote, AddColumn(AddColumnRoute), + EditProfile(Pubkey), Support, NewDeck, EditDeck(usize), @@ -104,6 +105,7 @@ impl Route { Route::Support => ColumnTitle::simple("Damus Support"), Route::NewDeck => ColumnTitle::simple("Add Deck"), Route::EditDeck(_) => ColumnTitle::simple("Edit Deck"), + Route::EditProfile(_) => ColumnTitle::simple("Edit Profile"), } } } @@ -215,6 +217,7 @@ impl fmt::Display for Route { Route::Support => write!(f, "Support"), Route::NewDeck => write!(f, "Add Deck"), Route::EditDeck(_) => write!(f, "Edit Deck"), + Route::EditProfile(_) => write!(f, "Edit Profile"), } } } diff --git a/crates/notedeck_columns/src/storage/decks.rs b/crates/notedeck_columns/src/storage/decks.rs @@ -541,6 +541,11 @@ fn serialize_route(route: &Route, columns: &Columns) -> Option<String> { selections.push(Selection::Keyword(Keyword::Edit)); selections.push(Selection::Payload(index.to_string())); } + Route::EditProfile(pubkey) => { + selections.push(Selection::Keyword(Keyword::Profile)); + selections.push(Selection::Keyword(Keyword::Edit)); + selections.push(Selection::Payload(pubkey.hex())); + } } if selections.is_empty() { @@ -649,6 +654,15 @@ fn selections_to_route(selections: Vec<Selection>) -> Option<CleanIntermediaryRo Selection::Keyword(Keyword::DeckAuthor) => Some(CleanIntermediaryRoute::ToTimeline( TimelineKind::profile(PubkeySource::DeckAuthor), )), + Selection::Keyword(Keyword::Edit) => { + if let Selection::Payload(hex) = selections.get(2)? { + Some(CleanIntermediaryRoute::ToRoute(Route::EditProfile( + Pubkey::from_hex(hex.as_str()).ok()?, + ))) + } else { + None + } + } _ => None, }, Selection::Keyword(Keyword::Universe) => { diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs @@ -241,10 +241,9 @@ impl<'a> TitleNeedsDb<'a> { let pubkey = pubkey_source.to_pubkey(deck_author); let profile = ndb.get_profile_by_pubkey(txn, pubkey); let m_name = profile - .ok() .as_ref() - .and_then(|p| crate::profile::get_profile_name(p)) - .map(|display_name| display_name.username()); + .ok() + .map(|p| crate::profile::get_display_name(Some(p)).name()); m_name.unwrap_or("Profile") } else { diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs @@ -3,7 +3,7 @@ use crate::{ draft::Drafts, nav::RenderNavAction, notes_holder::NotesHolderStorage, - profile::Profile, + profile::{Profile, ProfileAction}, thread::Thread, timeline::{TimelineId, TimelineKind}, ui::{ @@ -117,6 +117,7 @@ pub fn render_timeline_route( TimelineRoute::Profile(pubkey) => render_profile_route( &pubkey, + accounts, ndb, profiles, img_cache, @@ -155,6 +156,7 @@ pub fn render_timeline_route( #[allow(clippy::too_many_arguments)] pub fn render_profile_route( pubkey: &Pubkey, + accounts: &Accounts, ndb: &Ndb, profiles: &mut NotesHolderStorage<Profile>, img_cache: &mut ImageCache, @@ -163,8 +165,9 @@ pub fn render_profile_route( ui: &mut egui::Ui, is_muted: &MuteFun, ) -> Option<RenderNavAction> { - let note_action = ProfileView::new( + let action = ProfileView::new( pubkey, + accounts, col, profiles, ndb, @@ -174,5 +177,16 @@ pub fn render_profile_route( ) .ui(ui, is_muted); - note_action.map(RenderNavAction::NoteAction) + if let Some(action) = action { + match action { + ui::profile::ProfileViewAction::EditProfile => accounts + .get_full(pubkey.bytes()) + .map(|kp| RenderNavAction::ProfileAction(ProfileAction::Edit(kp.to_full()))), + ui::profile::ProfileViewAction::Note(note_action) => { + Some(RenderNavAction::NoteAction(note_action)) + } + } + } else { + None + } } diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -245,15 +245,7 @@ impl<'a> NavTitle<'a> { TimelineRoute::Quote(_note_id) => {} TimelineRoute::Profile(pubkey) => { - let txn = Transaction::new(self.ndb).unwrap(); - if let Some(pfp) = self.pubkey_pfp(&txn, pubkey.bytes(), pfp_size) { - ui.add(pfp); - } else { - ui.add( - ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()) - .size(pfp_size), - ); - } + self.show_profile(ui, pubkey, pfp_size); } }, @@ -264,9 +256,23 @@ impl<'a> NavTitle<'a> { Route::Relays => {} Route::NewDeck => {} Route::EditDeck(_) => {} + Route::EditProfile(pubkey) => { + self.show_profile(ui, pubkey, pfp_size); + } } } + fn show_profile(&mut self, ui: &mut egui::Ui, pubkey: &Pubkey, pfp_size: f32) { + let txn = Transaction::new(self.ndb).unwrap(); + if let Some(pfp) = self.pubkey_pfp(&txn, pubkey.bytes(), pfp_size) { + ui.add(pfp); + } else { + ui.add( + ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size), + ); + }; + } + fn title_label_value(title: &str) -> egui::Label { egui::Label::new(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style())) .selectable(false) diff --git a/crates/notedeck_columns/src/ui/mention.rs b/crates/notedeck_columns/src/ui/mention.rs @@ -1,5 +1,5 @@ -use crate::actionbar::NoteAction; use crate::ui; +use crate::{actionbar::NoteAction, profile::get_display_name}; use egui::Sense; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; @@ -79,12 +79,7 @@ fn mention_ui( ui.horizontal(|ui| { let profile = ndb.get_profile_by_pubkey(txn, pk).ok(); - let name: String = - if let Some(name) = profile.as_ref().and_then(crate::profile::get_profile_name) { - format!("@{}", name.username()) - } else { - "@???".to_string() - }; + let name: String = format!("@{}", get_display_name(profile.as_ref()).name()); let resp = ui.add( egui::Label::new(egui::RichText::new(name).color(link_color).size(size)) diff --git a/crates/notedeck_columns/src/ui/note/mod.rs b/crates/notedeck_columns/src/ui/note/mod.rs @@ -16,6 +16,7 @@ pub use reply_description::reply_desc; use crate::{ actionbar::NoteAction, + profile::get_display_name, ui::{self, View}, }; @@ -25,7 +26,7 @@ use enostr::{NoteId, Pubkey}; use nostrdb::{Ndb, Note, NoteKey, Transaction}; use notedeck::{CachedNote, ImageCache, NoteCache, NotedeckTextStyle}; -use super::profile::preview::{get_display_name, one_line_display_name_widget}; +use super::profile::preview::one_line_display_name_widget; pub struct NoteView<'a> { ndb: &'a Ndb, diff --git a/crates/notedeck_columns/src/ui/profile/edit.rs b/crates/notedeck_columns/src/ui/profile/edit.rs @@ -0,0 +1,205 @@ +use core::f32; + +use egui::{vec2, Button, Layout, Margin, RichText, Rounding, ScrollArea, TextEdit}; +use notedeck::{ImageCache, NotedeckTextStyle}; + +use crate::{colors, profile_state::ProfileState}; + +use super::{banner, unwrap_profile_url, ProfilePic}; + +pub struct EditProfileView<'a> { + state: &'a mut ProfileState, + img_cache: &'a mut ImageCache, +} + +impl<'a> EditProfileView<'a> { + pub fn new(state: &'a mut ProfileState, img_cache: &'a mut ImageCache) -> Self { + Self { state, img_cache } + } + + // return true to save + pub fn ui(&mut self, ui: &mut egui::Ui) -> bool { + ScrollArea::vertical() + .show(ui, |ui| { + banner(ui, Some(&self.state.banner), 188.0); + + let padding = 24.0; + crate::ui::padding(padding, ui, |ui| { + self.inner(ui, padding); + }); + + ui.separator(); + + let mut save = false; + crate::ui::padding(padding, ui, |ui| { + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add(button("Save changes", 119.0).fill(colors::PINK)) + .clicked() + { + save = true; + } + }); + }); + + save + }) + .inner + } + + fn inner(&mut self, ui: &mut egui::Ui, padding: f32) { + ui.spacing_mut().item_spacing = egui::vec2(0.0, 16.0); + let mut pfp_rect = ui.available_rect_before_wrap(); + let size = 80.0; + pfp_rect.set_width(size); + 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) + }); + ui.put( + pfp_rect, + ProfilePic::new(self.img_cache, pfp_url).size(size), + ); + + in_frame(ui, |ui| { + ui.add(label("Display name")); + ui.add(singleline_textedit(&mut self.state.display_name)); + }); + + in_frame(ui, |ui| { + ui.add(label("Username")); + ui.add(singleline_textedit(&mut self.state.name)); + }); + + in_frame(ui, |ui| { + ui.add(label("Profile picture")); + ui.add(multiline_textedit(&mut self.state.picture)); + }); + + in_frame(ui, |ui| { + ui.add(label("Banner")); + ui.add(multiline_textedit(&mut self.state.banner)); + }); + + in_frame(ui, |ui| { + ui.add(label("About")); + ui.add(multiline_textedit(&mut self.state.about)); + }); + + in_frame(ui, |ui| { + ui.add(label("Website")); + ui.add(singleline_textedit(&mut self.state.website)); + }); + + in_frame(ui, |ui| { + ui.add(label("Lightning network address (lud16)")); + ui.add(multiline_textedit(&mut self.state.lud16)); + }); + + in_frame(ui, |ui| { + ui.add(label("NIP-05 verification")); + 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 verification", suffix) + } else { + format!( + "\"{}\" at \"{}\" will be used for verification", + prefix, suffix + ) + }), + ); + } + } + }); + } +} + +fn label(text: &str) -> impl egui::Widget + '_ { + move |ui: &mut egui::Ui| -> egui::Response { + ui.label(RichText::new(text).font(NotedeckTextStyle::Body.get_bolded_font(ui.ctx()))) + } +} + +fn singleline_textedit(data: &mut String) -> impl egui::Widget + '_ { + TextEdit::singleline(data) + .min_size(vec2(0.0, 40.0)) + .vertical_align(egui::Align::Center) + .margin(Margin::symmetric(12.0, 10.0)) + .desired_width(f32::INFINITY) +} + +fn multiline_textedit(data: &mut String) -> impl egui::Widget + '_ { + TextEdit::multiline(data) + // .min_size(vec2(0.0, 40.0)) + .vertical_align(egui::Align::TOP) + .margin(Margin::symmetric(12.0, 10.0)) + .desired_width(f32::INFINITY) + .desired_rows(1) +} + +fn in_frame(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::none().show(ui, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(0.0, 8.0); + contents(ui); + }); +} + +fn button(text: &str, width: f32) -> egui::Button<'static> { + Button::new(text) + .rounding(Rounding::same(8.0)) + .min_size(vec2(width, 40.0)) +} + +mod preview { + use notedeck::App; + + 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) { + EditProfileView::new(&mut self.state, ctx.img_cache).ui(ui); + } + } + + impl<'a> Preview for EditProfileView<'a> { + type Prev = EditProfilePreivew; + + fn preview(_cfg: PreviewConfig) -> Self::Prev { + EditProfilePreivew::default() + } + } +} diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -1,11 +1,16 @@ +pub mod edit; pub mod picture; pub mod preview; -use crate::notes_holder::NotesHolder; +use crate::profile::get_display_name; use crate::ui::note::NoteOptions; -use egui::{ScrollArea, Widget}; +use crate::{colors, images}; +use crate::{notes_holder::NotesHolder, NostrName}; +pub use edit::EditProfileView; +use egui::load::TexturePoll; +use egui::{vec2, Color32, Label, Layout, Rect, RichText, Rounding, ScrollArea, Sense, Stroke}; use enostr::Pubkey; -use nostrdb::{Ndb, Transaction}; +use nostrdb::{Ndb, ProfileRecord, Transaction}; pub use picture::ProfilePic; pub use preview::ProfilePreview; use tracing::error; @@ -13,10 +18,11 @@ use tracing::error; use crate::{actionbar::NoteAction, notes_holder::NotesHolderStorage, profile::Profile}; use super::timeline::{tabs_ui, TimelineTabView}; -use notedeck::{ImageCache, MuteFun, NoteCache}; +use notedeck::{Accounts, ImageCache, MuteFun, NoteCache, NotedeckTextStyle}; pub struct ProfileView<'a> { pubkey: &'a Pubkey, + accounts: &'a Accounts, col_id: usize, profiles: &'a mut NotesHolderStorage<Profile>, note_options: NoteOptions, @@ -25,9 +31,16 @@ pub struct ProfileView<'a> { img_cache: &'a mut ImageCache, } +pub enum ProfileViewAction { + EditProfile, + Note(NoteAction), +} + impl<'a> ProfileView<'a> { + #[allow(clippy::too_many_arguments)] pub fn new( pubkey: &'a Pubkey, + accounts: &'a Accounts, col_id: usize, profiles: &'a mut NotesHolderStorage<Profile>, ndb: &'a Ndb, @@ -37,6 +50,7 @@ impl<'a> ProfileView<'a> { ) -> Self { ProfileView { pubkey, + accounts, col_id, profiles, ndb, @@ -46,15 +60,18 @@ impl<'a> ProfileView<'a> { } } - pub fn ui(&mut self, ui: &mut egui::Ui, is_muted: &MuteFun) -> Option<NoteAction> { + pub fn ui(&mut self, ui: &mut egui::Ui, is_muted: &MuteFun) -> Option<ProfileViewAction> { let scroll_id = egui::Id::new(("profile_scroll", self.col_id, self.pubkey)); ScrollArea::vertical() .id_salt(scroll_id) .show(ui, |ui| { + let mut action = None; let txn = Transaction::new(self.ndb).expect("txn"); if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, self.pubkey.bytes()) { - ProfilePreview::new(&profile, self.img_cache).ui(ui); + if self.profile_body(ui, profile) { + action = Some(ProfileViewAction::EditProfile); + } } let profile = self .profiles @@ -77,7 +94,7 @@ impl<'a> ProfileView<'a> { let reversed = false; - TimelineTabView::new( + if let Some(note_action) = TimelineTabView::new( profile.timeline.current_view(), reversed, self.note_options, @@ -87,7 +104,327 @@ impl<'a> ProfileView<'a> { self.img_cache, ) .show(ui) + { + action = Some(ProfileViewAction::Note(note_action)); + } + action }) .inner } + + fn profile_body(&mut self, ui: &mut egui::Ui, profile: ProfileRecord<'_>) -> bool { + let mut action = false; + ui.vertical(|ui| { + banner( + ui, + profile.record().profile().and_then(|p| p.banner()), + 120.0, + ); + + let padding = 12.0; + crate::ui::padding(padding, ui, |ui| { + let mut pfp_rect = ui.available_rect_before_wrap(); + let size = 80.0; + pfp_rect.set_width(size); + pfp_rect.set_height(size); + let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); + + ui.horizontal(|ui| { + ui.put( + pfp_rect, + ProfilePic::new(self.img_cache, get_profile_url(Some(&profile))).size(size), + ); + + if ui.add(copy_key_widget(&pfp_rect)).clicked() { + ui.output_mut(|w| { + w.copied_text = if let Some(bech) = self.pubkey.to_bech() { + bech + } else { + error!("Could not convert Pubkey to bech"); + String::new() + } + }); + } + + if self.accounts.contains_full_kp(self.pubkey) { + ui.with_layout(Layout::right_to_left(egui::Align::Max), |ui| { + if ui.add(edit_profile_button()).clicked() { + action = true; + } + }); + } + }); + + ui.add_space(18.0); + + ui.add(display_name_widget(get_display_name(Some(&profile)), false)); + + ui.add_space(8.0); + + ui.add(about_section_widget(&profile)); + + ui.horizontal_wrapped(|ui| { + if let Some(website_url) = profile + .record() + .profile() + .and_then(|p| p.website()) + .filter(|s| !s.is_empty()) + { + handle_link(ui, website_url); + } + + if let Some(lud16) = profile + .record() + .profile() + .and_then(|p| p.lud16()) + .filter(|s| !s.is_empty()) + { + handle_lud16(ui, lud16); + } + }); + }); + }); + + action + } +} + +fn handle_link(ui: &mut egui::Ui, website_url: &str) { + ui.image(egui::include_image!( + "../../../../../assets/icons/links_4x.png" + )); + if ui + .label(RichText::new(website_url).color(colors::PINK)) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .interact(Sense::click()) + .clicked() + { + if let Err(e) = open::that(website_url) { + error!("Failed to open URL {} because: {}", website_url, e); + }; + } +} + +fn handle_lud16(ui: &mut egui::Ui, lud16: &str) { + ui.image(egui::include_image!( + "../../../../../assets/icons/zap_4x.png" + )); + + let _ = ui.label(RichText::new(lud16).color(colors::PINK)); +} + +fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ { + |ui: &mut egui::Ui| -> egui::Response { + let painter = ui.painter(); + let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size( + pfp_rect.center_bottom(), + egui::vec2(48.0, 28.0), + )); + let resp = ui.interact( + copy_key_rect, + ui.id().with("custom_painter"), + Sense::click(), + ); + + let copy_key_rounding = Rounding::same(100.0); + let fill_color = if resp.hovered() { + ui.visuals().widgets.inactive.weak_bg_fill + } else { + ui.visuals().noninteractive().bg_stroke.color + }; + painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color); + + let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill; + painter.rect_stroke( + copy_key_rect.shrink(1.0), + copy_key_rounding, + Stroke::new(1.0, stroke_color), + ); + egui::Image::new(egui::include_image!( + "../../../../../assets/icons/key_4x.png" + )) + .paint_at( + ui, + painter.round_rect_to_pixels(egui::Rect::from_center_size( + copy_key_rect.center(), + egui::vec2(16.0, 16.0), + )), + ); + + resp + } +} + +fn edit_profile_button() -> impl egui::Widget + 'static { + |ui: &mut egui::Ui| -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click()); + let painter = ui.painter_at(rect); + let rect = painter.round_rect_to_pixels(rect); + + painter.rect_filled( + rect, + Rounding::same(8.0), + if resp.hovered() { + ui.visuals().widgets.active.bg_fill + } else { + ui.visuals().widgets.inactive.bg_fill + }, + ); + painter.rect_stroke( + rect.shrink(1.0), + Rounding::same(8.0), + if resp.hovered() { + ui.visuals().widgets.active.bg_stroke + } else { + ui.visuals().widgets.inactive.bg_stroke + }, + ); + + let edit_icon_size = vec2(16.0, 16.0); + let galley = painter.layout( + "Edit Profile".to_owned(), + NotedeckTextStyle::Button.get_font_id(ui.ctx()), + ui.visuals().text_color(), + rect.width(), + ); + + let space_between_icon_galley = 8.0; + let half_icon_size = edit_icon_size.x / 2.0; + let galley_rect = { + let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size()); + galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0)) + }; + + let edit_icon_rect = { + let mut center = galley_rect.left_center(); + center.x -= half_icon_size + space_between_icon_galley; + painter.round_rect_to_pixels(Rect::from_center_size( + painter.round_pos_to_pixel_center(center), + edit_icon_size, + )) + }; + + painter.galley(galley_rect.left_top(), galley, Color32::WHITE); + + egui::Image::new(egui::include_image!( + "../../../../../assets/icons/edit_icon_4x_dark.png" + )) + .paint_at(ui, edit_icon_rect); + + resp + } +} + +fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl egui::Widget + '_ { + move |ui: &mut egui::Ui| -> egui::Response { + let disp_resp = name.display_name.map(|disp_name| { + ui.add( + Label::new( + RichText::new(disp_name).text_style(NotedeckTextStyle::Heading3.text_style()), + ) + .selectable(false), + ) + }); + + let (username_resp, nip05_resp) = ui + .horizontal(|ui| { + let username_resp = name.username.map(|username| { + ui.add( + Label::new( + RichText::new(format!("@{}", username)) + .size(16.0) + .color(colors::MID_GRAY), + ) + .selectable(false), + ) + }); + + let nip05_resp = name.nip05.map(|nip05| { + ui.image(egui::include_image!( + "../../../../../assets/icons/verified_4x.png" + )); + ui.add(Label::new( + RichText::new(nip05).size(16.0).color(colors::TEAL), + )) + }); + + (username_resp, nip05_resp) + }) + .inner; + + let resp = match (disp_resp, username_resp, nip05_resp) { + (Some(disp), Some(username), Some(nip05)) => disp.union(username).union(nip05), + (Some(disp), Some(username), None) => disp.union(username), + (Some(disp), None, None) => disp, + (None, Some(username), Some(nip05)) => username.union(nip05), + (None, Some(username), None) => username, + _ => ui.add(Label::new(RichText::new(name.name()))), + }; + + if add_placeholder_space { + ui.add_space(16.0); + } + + resp + } +} + +pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str { + unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture()))) +} + +pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str { + if let Some(url) = maybe_url { + url + } else { + ProfilePic::no_pfp_url() + } +} + +fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b +where + 'b: 'a, +{ + move |ui: &mut egui::Ui| { + if let Some(about) = profile.record().profile().and_then(|p| p.about()) { + let resp = ui.label(about); + ui.add_space(8.0); + resp + } else { + // need any Response so we dont need an Option + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) + } + } +} + +fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option<egui::load::SizedTexture> { + // TODO: cache banner + if !banner_url.is_empty() { + let texture_load_res = + egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size()); + if let Ok(texture_poll) = texture_load_res { + match texture_poll { + TexturePoll::Pending { .. } => {} + TexturePoll::Ready { texture, .. } => return Some(texture), + } + } + } + + None +} + +fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response { + ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| { + banner_url + .and_then(|url| banner_texture(ui, url)) + .map(|texture| { + images::aspect_fill( + ui, + Sense::hover(), + texture.id, + texture.size.x / texture.size.y, + ) + }) + .unwrap_or_else(|| ui.label("")) + }) } diff --git a/crates/notedeck_columns/src/ui/profile/preview.rs b/crates/notedeck_columns/src/ui/profile/preview.rs @@ -1,13 +1,13 @@ use crate::ui::ProfilePic; -use crate::{colors, images, DisplayName}; -use egui::load::TexturePoll; -use egui::{Frame, Label, RichText, Sense, Widget}; +use crate::NostrName; +use egui::{Frame, Label, RichText, Widget}; use egui_extras::Size; -use enostr::{NoteId, Pubkey}; -use nostrdb::{Ndb, ProfileRecord, Transaction}; +use nostrdb::ProfileRecord; use notedeck::{ImageCache, NotedeckTextStyle, UserAccount}; +use super::{about_section_widget, banner, display_name_widget, get_display_name, get_profile_url}; + pub struct ProfilePreview<'a, 'cache> { profile: &'a ProfileRecord<'a>, cache: &'cache mut ImageCache, @@ -28,41 +28,6 @@ impl<'a, 'cache> ProfilePreview<'a, 'cache> { self.banner_height = size; } - fn banner_texture( - ui: &mut egui::Ui, - profile: &ProfileRecord<'_>, - ) -> Option<egui::load::SizedTexture> { - // TODO: cache banner - let banner = profile.record().profile().and_then(|p| p.banner()); - - if let Some(banner) = banner { - let texture_load_res = - egui::Image::new(banner).load_for_size(ui.ctx(), ui.available_size()); - if let Ok(texture_poll) = texture_load_res { - match texture_poll { - TexturePoll::Pending { .. } => {} - TexturePoll::Ready { texture, .. } => return Some(texture), - } - } - } - - None - } - - fn banner(ui: &mut egui::Ui, profile: &ProfileRecord<'_>) -> egui::Response { - if let Some(texture) = Self::banner_texture(ui, profile) { - images::aspect_fill( - ui, - Sense::hover(), - texture.id, - texture.size.x / texture.size.y, - ) - } else { - // TODO: default banner texture - ui.label("") - } - } - fn body(self, ui: &mut egui::Ui) { let padding = 12.0; crate::ui::padding(padding, ui, |ui| { @@ -88,9 +53,11 @@ impl<'a, 'cache> ProfilePreview<'a, 'cache> { impl egui::Widget for ProfilePreview<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { ui.vertical(|ui| { - ui.add_sized([ui.available_size().x, 80.0], |ui: &mut egui::Ui| { - ProfilePreview::banner(ui, self.profile) - }); + banner( + ui, + self.profile.record().profile().and_then(|p| p.banner()), + 80.0, + ); self.body(ui); }) @@ -183,22 +150,6 @@ mod previews { } } -pub fn get_display_name<'a>(profile: Option<&ProfileRecord<'a>>) -> DisplayName<'a> { - if let Some(name) = profile.and_then(|p| crate::profile::get_profile_name(p)) { - name - } else { - DisplayName::One("??") - } -} - -pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str { - if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) { - url - } else { - ProfilePic::no_pfp_url() - } -} - pub fn get_profile_url_owned(profile: Option<ProfileRecord<'_>>) -> &str { if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) { url @@ -223,106 +174,19 @@ pub fn get_account_url<'a>( } } -fn display_name_widget( - display_name: DisplayName<'_>, - add_placeholder_space: bool, -) -> impl egui::Widget + '_ { - move |ui: &mut egui::Ui| match display_name { - DisplayName::One(n) => { - let name_response = ui.add( - Label::new(RichText::new(n).text_style(NotedeckTextStyle::Heading3.text_style())) - .selectable(false), - ); - if add_placeholder_space { - ui.add_space(16.0); - } - name_response - } - - DisplayName::Both { - display_name, - username, - } => { - ui.add( - Label::new( - RichText::new(display_name) - .text_style(NotedeckTextStyle::Heading3.text_style()), - ) - .selectable(false), - ); - - ui.add( - Label::new( - RichText::new(format!("@{}", username)) - .size(12.0) - .color(colors::MID_GRAY), - ) - .selectable(false), - ) - } - } -} - pub fn one_line_display_name_widget<'a>( visuals: &egui::Visuals, - display_name: DisplayName<'a>, + display_name: NostrName<'a>, style: NotedeckTextStyle, ) -> impl egui::Widget + 'a { let text_style = style.text_style(); let color = visuals.noninteractive().fg_stroke.color; - move |ui: &mut egui::Ui| match display_name { - DisplayName::One(n) => ui.label(RichText::new(n).text_style(text_style).color(color)), - - DisplayName::Both { - display_name, - username: _, - } => ui.label( - RichText::new(display_name) + move |ui: &mut egui::Ui| -> egui::Response { + ui.label( + RichText::new(display_name.name()) .text_style(text_style) .color(color), - ), - } -} - -fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b -where - 'b: 'a, -{ - move |ui: &mut egui::Ui| { - if let Some(about) = profile.record().profile().and_then(|p| p.about()) { - ui.label(about) - } else { - // need any Response so we dont need an Option - ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) - } - } -} - -fn get_display_name_as_string<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str { - let display_name = get_display_name(profile); - match display_name { - DisplayName::One(n) => n, - DisplayName::Both { display_name, .. } => display_name, + ) } } - -pub fn get_profile_displayname_string<'a>(txn: &'a Transaction, ndb: &Ndb, pk: &Pubkey) -> &'a str { - let profile = ndb.get_profile_by_pubkey(txn, pk.bytes()).ok(); - get_display_name_as_string(profile.as_ref()) -} - -pub fn get_note_users_displayname_string<'a>( - txn: &'a Transaction, - ndb: &Ndb, - id: &NoteId, -) -> &'a str { - let note = ndb.get_note_by_id(txn, id.bytes()); - let profile = if let Ok(note) = note { - ndb.get_profile_by_pubkey(txn, note.pubkey()).ok() - } else { - None - }; - - get_display_name_as_string(profile.as_ref()) -} diff --git a/crates/notedeck_columns/src/view_state.rs b/crates/notedeck_columns/src/view_state.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; +use enostr::Pubkey; + use crate::deck_state::DeckState; use crate::login_manager::AcquireKeyState; +use crate::profile_state::ProfileState; /// Various state for views #[derive(Default)] @@ -10,6 +13,7 @@ pub struct ViewState { pub id_to_deck_state: HashMap<egui::Id, DeckState>, pub id_state_map: HashMap<egui::Id, AcquireKeyState>, pub id_string_map: HashMap<egui::Id, String>, + pub pubkey_to_profile_state: HashMap<Pubkey, ProfileState>, } impl ViewState {