mod.rs (14550B)
1 pub mod edit; 2 pub mod picture; 3 pub mod preview; 4 5 pub use edit::EditProfileView; 6 use egui::load::TexturePoll; 7 use egui::{vec2, Color32, Label, Layout, Rect, RichText, Rounding, ScrollArea, Sense, Stroke}; 8 use enostr::{Pubkey, PubkeyRef}; 9 use nostrdb::{Ndb, ProfileRecord, Transaction}; 10 pub use picture::ProfilePic; 11 pub use preview::ProfilePreview; 12 use tracing::error; 13 14 use crate::{ 15 actionbar::NoteAction, 16 colors, images, 17 profile::get_display_name, 18 timeline::{TimelineCache, TimelineCacheKey}, 19 ui::{ 20 note::NoteOptions, 21 timeline::{tabs_ui, TimelineTabView}, 22 }, 23 NostrName, 24 }; 25 26 use notedeck::{Accounts, ImageCache, MuteFun, NoteCache, NotedeckTextStyle, UnknownIds}; 27 28 pub struct ProfileView<'a> { 29 pubkey: &'a Pubkey, 30 accounts: &'a Accounts, 31 col_id: usize, 32 timeline_cache: &'a mut TimelineCache, 33 note_options: NoteOptions, 34 ndb: &'a Ndb, 35 note_cache: &'a mut NoteCache, 36 img_cache: &'a mut ImageCache, 37 unknown_ids: &'a mut UnknownIds, 38 is_muted: &'a MuteFun, 39 } 40 41 pub enum ProfileViewAction { 42 EditProfile, 43 Note(NoteAction), 44 } 45 46 impl<'a> ProfileView<'a> { 47 #[allow(clippy::too_many_arguments)] 48 pub fn new( 49 pubkey: &'a Pubkey, 50 accounts: &'a Accounts, 51 col_id: usize, 52 timeline_cache: &'a mut TimelineCache, 53 ndb: &'a Ndb, 54 note_cache: &'a mut NoteCache, 55 img_cache: &'a mut ImageCache, 56 unknown_ids: &'a mut UnknownIds, 57 is_muted: &'a MuteFun, 58 note_options: NoteOptions, 59 ) -> Self { 60 ProfileView { 61 pubkey, 62 accounts, 63 col_id, 64 timeline_cache, 65 ndb, 66 note_cache, 67 img_cache, 68 unknown_ids, 69 note_options, 70 is_muted, 71 } 72 } 73 74 pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<ProfileViewAction> { 75 let scroll_id = egui::Id::new(("profile_scroll", self.col_id, self.pubkey)); 76 77 ScrollArea::vertical() 78 .id_salt(scroll_id) 79 .show(ui, |ui| { 80 let mut action = None; 81 let txn = Transaction::new(self.ndb).expect("txn"); 82 if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, self.pubkey.bytes()) { 83 if self.profile_body(ui, profile) { 84 action = Some(ProfileViewAction::EditProfile); 85 } 86 } 87 let profile_timeline = self 88 .timeline_cache 89 .notes( 90 self.ndb, 91 self.note_cache, 92 &txn, 93 TimelineCacheKey::Profile(PubkeyRef::new(self.pubkey.bytes())), 94 ) 95 .get_ptr(); 96 97 profile_timeline.selected_view = 98 tabs_ui(ui, profile_timeline.selected_view, &profile_timeline.views); 99 100 let reversed = false; 101 // poll for new notes and insert them into our existing notes 102 if let Err(e) = profile_timeline.poll_notes_into_view( 103 self.ndb, 104 &txn, 105 self.unknown_ids, 106 self.note_cache, 107 reversed, 108 ) { 109 error!("Profile::poll_notes_into_view: {e}"); 110 } 111 112 if let Some(note_action) = TimelineTabView::new( 113 profile_timeline.current_view(), 114 reversed, 115 self.note_options, 116 &txn, 117 self.ndb, 118 self.note_cache, 119 self.img_cache, 120 self.is_muted, 121 ) 122 .show(ui) 123 { 124 action = Some(ProfileViewAction::Note(note_action)); 125 } 126 127 action 128 }) 129 .inner 130 } 131 132 fn profile_body(&mut self, ui: &mut egui::Ui, profile: ProfileRecord<'_>) -> bool { 133 let mut action = false; 134 ui.vertical(|ui| { 135 banner( 136 ui, 137 profile.record().profile().and_then(|p| p.banner()), 138 120.0, 139 ); 140 141 let padding = 12.0; 142 crate::ui::padding(padding, ui, |ui| { 143 let mut pfp_rect = ui.available_rect_before_wrap(); 144 let size = 80.0; 145 pfp_rect.set_width(size); 146 pfp_rect.set_height(size); 147 let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); 148 149 ui.horizontal(|ui| { 150 ui.put( 151 pfp_rect, 152 ProfilePic::new(self.img_cache, get_profile_url(Some(&profile))).size(size), 153 ); 154 155 if ui.add(copy_key_widget(&pfp_rect)).clicked() { 156 ui.output_mut(|w| { 157 w.copied_text = if let Some(bech) = self.pubkey.to_bech() { 158 bech 159 } else { 160 error!("Could not convert Pubkey to bech"); 161 String::new() 162 } 163 }); 164 } 165 166 if self.accounts.contains_full_kp(self.pubkey) { 167 ui.with_layout(Layout::right_to_left(egui::Align::Max), |ui| { 168 if ui.add(edit_profile_button()).clicked() { 169 action = true; 170 } 171 }); 172 } 173 }); 174 175 ui.add_space(18.0); 176 177 ui.add(display_name_widget(get_display_name(Some(&profile)), false)); 178 179 ui.add_space(8.0); 180 181 ui.add(about_section_widget(&profile)); 182 183 ui.horizontal_wrapped(|ui| { 184 if let Some(website_url) = profile 185 .record() 186 .profile() 187 .and_then(|p| p.website()) 188 .filter(|s| !s.is_empty()) 189 { 190 handle_link(ui, website_url); 191 } 192 193 if let Some(lud16) = profile 194 .record() 195 .profile() 196 .and_then(|p| p.lud16()) 197 .filter(|s| !s.is_empty()) 198 { 199 handle_lud16(ui, lud16); 200 } 201 }); 202 }); 203 }); 204 205 action 206 } 207 } 208 209 fn handle_link(ui: &mut egui::Ui, website_url: &str) { 210 ui.image(egui::include_image!( 211 "../../../../../assets/icons/links_4x.png" 212 )); 213 if ui 214 .label(RichText::new(website_url).color(colors::PINK)) 215 .on_hover_cursor(egui::CursorIcon::PointingHand) 216 .interact(Sense::click()) 217 .clicked() 218 { 219 if let Err(e) = open::that(website_url) { 220 error!("Failed to open URL {} because: {}", website_url, e); 221 }; 222 } 223 } 224 225 fn handle_lud16(ui: &mut egui::Ui, lud16: &str) { 226 ui.image(egui::include_image!( 227 "../../../../../assets/icons/zap_4x.png" 228 )); 229 230 let _ = ui.label(RichText::new(lud16).color(colors::PINK)); 231 } 232 233 fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ { 234 |ui: &mut egui::Ui| -> egui::Response { 235 let painter = ui.painter(); 236 let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size( 237 pfp_rect.center_bottom(), 238 egui::vec2(48.0, 28.0), 239 )); 240 let resp = ui.interact( 241 copy_key_rect, 242 ui.id().with("custom_painter"), 243 Sense::click(), 244 ); 245 246 let copy_key_rounding = Rounding::same(100.0); 247 let fill_color = if resp.hovered() { 248 ui.visuals().widgets.inactive.weak_bg_fill 249 } else { 250 ui.visuals().noninteractive().bg_stroke.color 251 }; 252 painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color); 253 254 let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill; 255 painter.rect_stroke( 256 copy_key_rect.shrink(1.0), 257 copy_key_rounding, 258 Stroke::new(1.0, stroke_color), 259 ); 260 egui::Image::new(egui::include_image!( 261 "../../../../../assets/icons/key_4x.png" 262 )) 263 .paint_at( 264 ui, 265 painter.round_rect_to_pixels(egui::Rect::from_center_size( 266 copy_key_rect.center(), 267 egui::vec2(16.0, 16.0), 268 )), 269 ); 270 271 resp 272 } 273 } 274 275 fn edit_profile_button() -> impl egui::Widget + 'static { 276 |ui: &mut egui::Ui| -> egui::Response { 277 let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click()); 278 let painter = ui.painter_at(rect); 279 let rect = painter.round_rect_to_pixels(rect); 280 281 painter.rect_filled( 282 rect, 283 Rounding::same(8.0), 284 if resp.hovered() { 285 ui.visuals().widgets.active.bg_fill 286 } else { 287 ui.visuals().widgets.inactive.bg_fill 288 }, 289 ); 290 painter.rect_stroke( 291 rect.shrink(1.0), 292 Rounding::same(8.0), 293 if resp.hovered() { 294 ui.visuals().widgets.active.bg_stroke 295 } else { 296 ui.visuals().widgets.inactive.bg_stroke 297 }, 298 ); 299 300 let edit_icon_size = vec2(16.0, 16.0); 301 let galley = painter.layout( 302 "Edit Profile".to_owned(), 303 NotedeckTextStyle::Button.get_font_id(ui.ctx()), 304 ui.visuals().text_color(), 305 rect.width(), 306 ); 307 308 let space_between_icon_galley = 8.0; 309 let half_icon_size = edit_icon_size.x / 2.0; 310 let galley_rect = { 311 let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size()); 312 galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0)) 313 }; 314 315 let edit_icon_rect = { 316 let mut center = galley_rect.left_center(); 317 center.x -= half_icon_size + space_between_icon_galley; 318 painter.round_rect_to_pixels(Rect::from_center_size( 319 painter.round_pos_to_pixel_center(center), 320 edit_icon_size, 321 )) 322 }; 323 324 painter.galley(galley_rect.left_top(), galley, Color32::WHITE); 325 326 egui::Image::new(egui::include_image!( 327 "../../../../../assets/icons/edit_icon_4x_dark.png" 328 )) 329 .paint_at(ui, edit_icon_rect); 330 331 resp 332 } 333 } 334 335 fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl egui::Widget + '_ { 336 move |ui: &mut egui::Ui| -> egui::Response { 337 let disp_resp = name.display_name.map(|disp_name| { 338 ui.add( 339 Label::new( 340 RichText::new(disp_name).text_style(NotedeckTextStyle::Heading3.text_style()), 341 ) 342 .selectable(false), 343 ) 344 }); 345 346 let (username_resp, nip05_resp) = ui 347 .horizontal(|ui| { 348 let username_resp = name.username.map(|username| { 349 ui.add( 350 Label::new( 351 RichText::new(format!("@{}", username)) 352 .size(16.0) 353 .color(colors::MID_GRAY), 354 ) 355 .selectable(false), 356 ) 357 }); 358 359 let nip05_resp = name.nip05.map(|nip05| { 360 ui.image(egui::include_image!( 361 "../../../../../assets/icons/verified_4x.png" 362 )); 363 ui.add(Label::new( 364 RichText::new(nip05).size(16.0).color(colors::TEAL), 365 )) 366 }); 367 368 (username_resp, nip05_resp) 369 }) 370 .inner; 371 372 let resp = match (disp_resp, username_resp, nip05_resp) { 373 (Some(disp), Some(username), Some(nip05)) => disp.union(username).union(nip05), 374 (Some(disp), Some(username), None) => disp.union(username), 375 (Some(disp), None, None) => disp, 376 (None, Some(username), Some(nip05)) => username.union(nip05), 377 (None, Some(username), None) => username, 378 _ => ui.add(Label::new(RichText::new(name.name()))), 379 }; 380 381 if add_placeholder_space { 382 ui.add_space(16.0); 383 } 384 385 resp 386 } 387 } 388 389 pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str { 390 unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture()))) 391 } 392 393 pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str { 394 if let Some(url) = maybe_url { 395 url 396 } else { 397 ProfilePic::no_pfp_url() 398 } 399 } 400 401 fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b 402 where 403 'b: 'a, 404 { 405 move |ui: &mut egui::Ui| { 406 if let Some(about) = profile.record().profile().and_then(|p| p.about()) { 407 let resp = ui.label(about); 408 ui.add_space(8.0); 409 resp 410 } else { 411 // need any Response so we dont need an Option 412 ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) 413 } 414 } 415 } 416 417 fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option<egui::load::SizedTexture> { 418 // TODO: cache banner 419 if !banner_url.is_empty() { 420 let texture_load_res = 421 egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size()); 422 if let Ok(texture_poll) = texture_load_res { 423 match texture_poll { 424 TexturePoll::Pending { .. } => {} 425 TexturePoll::Ready { texture, .. } => return Some(texture), 426 } 427 } 428 } 429 430 None 431 } 432 433 fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response { 434 ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| { 435 banner_url 436 .and_then(|url| banner_texture(ui, url)) 437 .map(|texture| { 438 images::aspect_fill( 439 ui, 440 Sense::hover(), 441 texture.id, 442 texture.size.x / texture.size.y, 443 ) 444 }) 445 .unwrap_or_else(|| ui.label("")) 446 }) 447 }