mod.rs (14591B)
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; 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, TimelineKind}, 19 ui::{ 20 note::NoteOptions, 21 timeline::{tabs_ui, TimelineTabView}, 22 }, 23 NostrName, 24 }; 25 26 use notedeck::{Accounts, Images, 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 Images, 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 Images, 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 &TimelineKind::Profile(*self.pubkey), 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))) 153 .size(size) 154 .border(ProfilePic::border_stroke(ui)), 155 ); 156 157 if ui.add(copy_key_widget(&pfp_rect)).clicked() { 158 ui.output_mut(|w| { 159 w.copied_text = if let Some(bech) = self.pubkey.to_bech() { 160 bech 161 } else { 162 error!("Could not convert Pubkey to bech"); 163 String::new() 164 } 165 }); 166 } 167 168 if self.accounts.contains_full_kp(self.pubkey) { 169 ui.with_layout(Layout::right_to_left(egui::Align::Max), |ui| { 170 if ui.add(edit_profile_button()).clicked() { 171 action = true; 172 } 173 }); 174 } 175 }); 176 177 ui.add_space(18.0); 178 179 ui.add(display_name_widget(get_display_name(Some(&profile)), false)); 180 181 ui.add_space(8.0); 182 183 ui.add(about_section_widget(&profile)); 184 185 ui.horizontal_wrapped(|ui| { 186 if let Some(website_url) = profile 187 .record() 188 .profile() 189 .and_then(|p| p.website()) 190 .filter(|s| !s.is_empty()) 191 { 192 handle_link(ui, website_url); 193 } 194 195 if let Some(lud16) = profile 196 .record() 197 .profile() 198 .and_then(|p| p.lud16()) 199 .filter(|s| !s.is_empty()) 200 { 201 handle_lud16(ui, lud16); 202 } 203 }); 204 }); 205 }); 206 207 action 208 } 209 } 210 211 fn handle_link(ui: &mut egui::Ui, website_url: &str) { 212 ui.image(egui::include_image!( 213 "../../../../../assets/icons/links_4x.png" 214 )); 215 if ui 216 .label(RichText::new(website_url).color(colors::PINK)) 217 .on_hover_cursor(egui::CursorIcon::PointingHand) 218 .interact(Sense::click()) 219 .clicked() 220 { 221 if let Err(e) = open::that(website_url) { 222 error!("Failed to open URL {} because: {}", website_url, e); 223 }; 224 } 225 } 226 227 fn handle_lud16(ui: &mut egui::Ui, lud16: &str) { 228 ui.image(egui::include_image!( 229 "../../../../../assets/icons/zap_4x.png" 230 )); 231 232 let _ = ui.label(RichText::new(lud16).color(colors::PINK)); 233 } 234 235 fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ { 236 |ui: &mut egui::Ui| -> egui::Response { 237 let painter = ui.painter(); 238 let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size( 239 pfp_rect.center_bottom(), 240 egui::vec2(48.0, 28.0), 241 )); 242 let resp = ui.interact( 243 copy_key_rect, 244 ui.id().with("custom_painter"), 245 Sense::click(), 246 ); 247 248 let copy_key_rounding = Rounding::same(100.0); 249 let fill_color = if resp.hovered() { 250 ui.visuals().widgets.inactive.weak_bg_fill 251 } else { 252 ui.visuals().noninteractive().bg_stroke.color 253 }; 254 painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color); 255 256 let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill; 257 painter.rect_stroke( 258 copy_key_rect.shrink(1.0), 259 copy_key_rounding, 260 Stroke::new(1.0, stroke_color), 261 ); 262 egui::Image::new(egui::include_image!( 263 "../../../../../assets/icons/key_4x.png" 264 )) 265 .paint_at( 266 ui, 267 painter.round_rect_to_pixels(egui::Rect::from_center_size( 268 copy_key_rect.center(), 269 egui::vec2(16.0, 16.0), 270 )), 271 ); 272 273 resp 274 } 275 } 276 277 fn edit_profile_button() -> impl egui::Widget + 'static { 278 |ui: &mut egui::Ui| -> egui::Response { 279 let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click()); 280 let painter = ui.painter_at(rect); 281 let rect = painter.round_rect_to_pixels(rect); 282 283 painter.rect_filled( 284 rect, 285 Rounding::same(8.0), 286 if resp.hovered() { 287 ui.visuals().widgets.active.bg_fill 288 } else { 289 ui.visuals().widgets.inactive.bg_fill 290 }, 291 ); 292 painter.rect_stroke( 293 rect.shrink(1.0), 294 Rounding::same(8.0), 295 if resp.hovered() { 296 ui.visuals().widgets.active.bg_stroke 297 } else { 298 ui.visuals().widgets.inactive.bg_stroke 299 }, 300 ); 301 302 let edit_icon_size = vec2(16.0, 16.0); 303 let galley = painter.layout( 304 "Edit Profile".to_owned(), 305 NotedeckTextStyle::Button.get_font_id(ui.ctx()), 306 ui.visuals().text_color(), 307 rect.width(), 308 ); 309 310 let space_between_icon_galley = 8.0; 311 let half_icon_size = edit_icon_size.x / 2.0; 312 let galley_rect = { 313 let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size()); 314 galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0)) 315 }; 316 317 let edit_icon_rect = { 318 let mut center = galley_rect.left_center(); 319 center.x -= half_icon_size + space_between_icon_galley; 320 painter.round_rect_to_pixels(Rect::from_center_size( 321 painter.round_pos_to_pixel_center(center), 322 edit_icon_size, 323 )) 324 }; 325 326 painter.galley(galley_rect.left_top(), galley, Color32::WHITE); 327 328 egui::Image::new(egui::include_image!( 329 "../../../../../assets/icons/edit_icon_4x_dark.png" 330 )) 331 .paint_at(ui, edit_icon_rect); 332 333 resp 334 } 335 } 336 337 fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl egui::Widget + '_ { 338 move |ui: &mut egui::Ui| -> egui::Response { 339 let disp_resp = name.display_name.map(|disp_name| { 340 ui.add( 341 Label::new( 342 RichText::new(disp_name).text_style(NotedeckTextStyle::Heading3.text_style()), 343 ) 344 .selectable(false), 345 ) 346 }); 347 348 let (username_resp, nip05_resp) = ui 349 .horizontal(|ui| { 350 let username_resp = name.username.map(|username| { 351 ui.add( 352 Label::new( 353 RichText::new(format!("@{}", username)) 354 .size(16.0) 355 .color(colors::MID_GRAY), 356 ) 357 .selectable(false), 358 ) 359 }); 360 361 let nip05_resp = name.nip05.map(|nip05| { 362 ui.image(egui::include_image!( 363 "../../../../../assets/icons/verified_4x.png" 364 )); 365 ui.add(Label::new( 366 RichText::new(nip05).size(16.0).color(colors::TEAL), 367 )) 368 }); 369 370 (username_resp, nip05_resp) 371 }) 372 .inner; 373 374 let resp = match (disp_resp, username_resp, nip05_resp) { 375 (Some(disp), Some(username), Some(nip05)) => disp.union(username).union(nip05), 376 (Some(disp), Some(username), None) => disp.union(username), 377 (Some(disp), None, None) => disp, 378 (None, Some(username), Some(nip05)) => username.union(nip05), 379 (None, Some(username), None) => username, 380 _ => ui.add(Label::new(RichText::new(name.name()))), 381 }; 382 383 if add_placeholder_space { 384 ui.add_space(16.0); 385 } 386 387 resp 388 } 389 } 390 391 pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str { 392 unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture()))) 393 } 394 395 pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str { 396 if let Some(url) = maybe_url { 397 url 398 } else { 399 ProfilePic::no_pfp_url() 400 } 401 } 402 403 fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b 404 where 405 'b: 'a, 406 { 407 move |ui: &mut egui::Ui| { 408 if let Some(about) = profile.record().profile().and_then(|p| p.about()) { 409 let resp = ui.label(about); 410 ui.add_space(8.0); 411 resp 412 } else { 413 // need any Response so we dont need an Option 414 ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) 415 } 416 } 417 } 418 419 fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option<egui::load::SizedTexture> { 420 // TODO: cache banner 421 if !banner_url.is_empty() { 422 let texture_load_res = 423 egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size()); 424 if let Ok(texture_poll) = texture_load_res { 425 match texture_poll { 426 TexturePoll::Pending { .. } => {} 427 TexturePoll::Ready { texture, .. } => return Some(texture), 428 } 429 } 430 } 431 432 None 433 } 434 435 fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response { 436 ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| { 437 banner_url 438 .and_then(|url| banner_texture(ui, url)) 439 .map(|texture| { 440 images::aspect_fill( 441 ui, 442 Sense::hover(), 443 texture.id, 444 texture.size.x / texture.size.y, 445 ) 446 }) 447 .unwrap_or_else(|| ui.label("")) 448 }) 449 }