pub mod edit; pub mod picture; pub mod preview; 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::{ProfileRecord, Transaction}; pub use picture::ProfilePic; pub use preview::ProfilePreview; use tracing::error; use crate::{ actionbar::NoteAction, colors, images, profile::get_display_name, timeline::{TimelineCache, TimelineKind}, ui::timeline::{tabs_ui, TimelineTabView}, NostrName, }; use notedeck::{Accounts, MuteFun, NotedeckTextStyle, UnknownIds}; use super::note::contents::NoteContext; use super::note::NoteOptions; pub struct ProfileView<'a, 'd> { pubkey: &'a Pubkey, accounts: &'a Accounts, col_id: usize, timeline_cache: &'a mut TimelineCache, note_options: NoteOptions, unknown_ids: &'a mut UnknownIds, is_muted: &'a MuteFun, note_context: &'a mut NoteContext<'d>, } pub enum ProfileViewAction { EditProfile, Note(NoteAction), } impl<'a, 'd> ProfileView<'a, 'd> { #[allow(clippy::too_many_arguments)] pub fn new( pubkey: &'a Pubkey, accounts: &'a Accounts, col_id: usize, timeline_cache: &'a mut TimelineCache, note_options: NoteOptions, unknown_ids: &'a mut UnknownIds, is_muted: &'a MuteFun, note_context: &'a mut NoteContext<'d>, ) -> Self { ProfileView { pubkey, accounts, col_id, timeline_cache, note_options, unknown_ids, is_muted, note_context, } } pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { 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.note_context.ndb).expect("txn"); if let Ok(profile) = self .note_context .ndb .get_profile_by_pubkey(&txn, self.pubkey.bytes()) { if self.profile_body(ui, profile) { action = Some(ProfileViewAction::EditProfile); } } let profile_timeline = self .timeline_cache .notes( self.note_context.ndb, self.note_context.note_cache, &txn, &TimelineKind::Profile(*self.pubkey), ) .get_ptr(); profile_timeline.selected_view = tabs_ui(ui, profile_timeline.selected_view, &profile_timeline.views); let reversed = false; // poll for new notes and insert them into our existing notes if let Err(e) = profile_timeline.poll_notes_into_view( self.note_context.ndb, &txn, self.unknown_ids, self.note_context.note_cache, reversed, ) { error!("Profile::poll_notes_into_view: {e}"); } if let Some(note_action) = TimelineTabView::new( profile_timeline.current_view(), reversed, self.note_options, &txn, self.is_muted, self.note_context, ) .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.note_context.img_cache, get_profile_url(Some(&profile)), ) .size(size) .border(ProfilePic::border_stroke(ui)), ); if ui.add(copy_key_widget(&pfp_rect)).clicked() { let to_copy = if let Some(bech) = self.pubkey.to_bech() { bech } else { error!("Could not convert Pubkey to bech"); String::new() }; ui.ctx().copy_text(to_copy) } 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); 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), 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), 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 { // 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("")) }) }