mod.rs (14732B)
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::{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::timeline::{tabs_ui, TimelineTabView}, 20 NostrName, 21 }; 22 23 use notedeck::{Accounts, MuteFun, NotedeckTextStyle, UnknownIds}; 24 25 use super::note::contents::NoteContext; 26 use super::note::NoteOptions; 27 28 pub struct ProfileView<'a, 'd> { 29 pubkey: &'a Pubkey, 30 accounts: &'a Accounts, 31 col_id: usize, 32 timeline_cache: &'a mut TimelineCache, 33 note_options: NoteOptions, 34 unknown_ids: &'a mut UnknownIds, 35 is_muted: &'a MuteFun, 36 note_context: &'a mut NoteContext<'d>, 37 } 38 39 pub enum ProfileViewAction { 40 EditProfile, 41 Note(NoteAction), 42 } 43 44 impl<'a, 'd> ProfileView<'a, 'd> { 45 #[allow(clippy::too_many_arguments)] 46 pub fn new( 47 pubkey: &'a Pubkey, 48 accounts: &'a Accounts, 49 col_id: usize, 50 timeline_cache: &'a mut TimelineCache, 51 note_options: NoteOptions, 52 unknown_ids: &'a mut UnknownIds, 53 is_muted: &'a MuteFun, 54 note_context: &'a mut NoteContext<'d>, 55 ) -> Self { 56 ProfileView { 57 pubkey, 58 accounts, 59 col_id, 60 timeline_cache, 61 note_options, 62 unknown_ids, 63 is_muted, 64 note_context, 65 } 66 } 67 68 pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<ProfileViewAction> { 69 let scroll_id = egui::Id::new(("profile_scroll", self.col_id, self.pubkey)); 70 71 ScrollArea::vertical() 72 .id_salt(scroll_id) 73 .show(ui, |ui| { 74 let mut action = None; 75 let txn = Transaction::new(self.note_context.ndb).expect("txn"); 76 if let Ok(profile) = self 77 .note_context 78 .ndb 79 .get_profile_by_pubkey(&txn, self.pubkey.bytes()) 80 { 81 if self.profile_body(ui, profile) { 82 action = Some(ProfileViewAction::EditProfile); 83 } 84 } 85 let profile_timeline = self 86 .timeline_cache 87 .notes( 88 self.note_context.ndb, 89 self.note_context.note_cache, 90 &txn, 91 &TimelineKind::Profile(*self.pubkey), 92 ) 93 .get_ptr(); 94 95 profile_timeline.selected_view = 96 tabs_ui(ui, profile_timeline.selected_view, &profile_timeline.views); 97 98 let reversed = false; 99 // poll for new notes and insert them into our existing notes 100 if let Err(e) = profile_timeline.poll_notes_into_view( 101 self.note_context.ndb, 102 &txn, 103 self.unknown_ids, 104 self.note_context.note_cache, 105 reversed, 106 ) { 107 error!("Profile::poll_notes_into_view: {e}"); 108 } 109 110 if let Some(note_action) = TimelineTabView::new( 111 profile_timeline.current_view(), 112 reversed, 113 self.note_options, 114 &txn, 115 self.is_muted, 116 self.note_context, 117 ) 118 .show(ui) 119 { 120 action = Some(ProfileViewAction::Note(note_action)); 121 } 122 123 action 124 }) 125 .inner 126 } 127 128 fn profile_body(&mut self, ui: &mut egui::Ui, profile: ProfileRecord<'_>) -> bool { 129 let mut action = false; 130 ui.vertical(|ui| { 131 banner( 132 ui, 133 profile.record().profile().and_then(|p| p.banner()), 134 120.0, 135 ); 136 137 let padding = 12.0; 138 crate::ui::padding(padding, ui, |ui| { 139 let mut pfp_rect = ui.available_rect_before_wrap(); 140 let size = 80.0; 141 pfp_rect.set_width(size); 142 pfp_rect.set_height(size); 143 let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); 144 145 ui.horizontal(|ui| { 146 ui.put( 147 pfp_rect, 148 ProfilePic::new( 149 self.note_context.img_cache, 150 get_profile_url(Some(&profile)), 151 ) 152 .size(size) 153 .border(ProfilePic::border_stroke(ui)), 154 ); 155 156 if ui.add(copy_key_widget(&pfp_rect)).clicked() { 157 let to_copy = 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 ui.ctx().copy_text(to_copy) 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 #[allow(deprecated)] 237 let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size( 238 pfp_rect.center_bottom(), 239 egui::vec2(48.0, 28.0), 240 )); 241 let resp = ui.interact( 242 copy_key_rect, 243 ui.id().with("custom_painter"), 244 Sense::click(), 245 ); 246 247 let copy_key_rounding = Rounding::same(100); 248 let fill_color = if resp.hovered() { 249 ui.visuals().widgets.inactive.weak_bg_fill 250 } else { 251 ui.visuals().noninteractive().bg_stroke.color 252 }; 253 painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color); 254 255 let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill; 256 painter.rect_stroke( 257 copy_key_rect.shrink(1.0), 258 copy_key_rounding, 259 Stroke::new(1.0, stroke_color), 260 ); 261 egui::Image::new(egui::include_image!( 262 "../../../../../assets/icons/key_4x.png" 263 )) 264 .paint_at( 265 ui, 266 #[allow(deprecated)] 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 #[allow(deprecated)] 282 let rect = painter.round_rect_to_pixels(rect); 283 284 painter.rect_filled( 285 rect, 286 Rounding::same(8), 287 if resp.hovered() { 288 ui.visuals().widgets.active.bg_fill 289 } else { 290 ui.visuals().widgets.inactive.bg_fill 291 }, 292 ); 293 painter.rect_stroke( 294 rect.shrink(1.0), 295 Rounding::same(8), 296 if resp.hovered() { 297 ui.visuals().widgets.active.bg_stroke 298 } else { 299 ui.visuals().widgets.inactive.bg_stroke 300 }, 301 ); 302 303 let edit_icon_size = vec2(16.0, 16.0); 304 let galley = painter.layout( 305 "Edit Profile".to_owned(), 306 NotedeckTextStyle::Button.get_font_id(ui.ctx()), 307 ui.visuals().text_color(), 308 rect.width(), 309 ); 310 311 let space_between_icon_galley = 8.0; 312 let half_icon_size = edit_icon_size.x / 2.0; 313 let galley_rect = { 314 let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size()); 315 galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0)) 316 }; 317 318 let edit_icon_rect = { 319 let mut center = galley_rect.left_center(); 320 center.x -= half_icon_size + space_between_icon_galley; 321 #[allow(deprecated)] 322 painter.round_rect_to_pixels(Rect::from_center_size( 323 painter.round_pos_to_pixel_center(center), 324 edit_icon_size, 325 )) 326 }; 327 328 painter.galley(galley_rect.left_top(), galley, Color32::WHITE); 329 330 egui::Image::new(egui::include_image!( 331 "../../../../../assets/icons/edit_icon_4x_dark.png" 332 )) 333 .paint_at(ui, edit_icon_rect); 334 335 resp 336 } 337 } 338 339 fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl egui::Widget + '_ { 340 move |ui: &mut egui::Ui| -> egui::Response { 341 let disp_resp = name.display_name.map(|disp_name| { 342 ui.add( 343 Label::new( 344 RichText::new(disp_name).text_style(NotedeckTextStyle::Heading3.text_style()), 345 ) 346 .selectable(false), 347 ) 348 }); 349 350 let (username_resp, nip05_resp) = ui 351 .horizontal(|ui| { 352 let username_resp = name.username.map(|username| { 353 ui.add( 354 Label::new( 355 RichText::new(format!("@{}", username)) 356 .size(16.0) 357 .color(colors::MID_GRAY), 358 ) 359 .selectable(false), 360 ) 361 }); 362 363 let nip05_resp = name.nip05.map(|nip05| { 364 ui.image(egui::include_image!( 365 "../../../../../assets/icons/verified_4x.png" 366 )); 367 ui.add(Label::new( 368 RichText::new(nip05).size(16.0).color(colors::TEAL), 369 )) 370 }); 371 372 (username_resp, nip05_resp) 373 }) 374 .inner; 375 376 let resp = match (disp_resp, username_resp, nip05_resp) { 377 (Some(disp), Some(username), Some(nip05)) => disp.union(username).union(nip05), 378 (Some(disp), Some(username), None) => disp.union(username), 379 (Some(disp), None, None) => disp, 380 (None, Some(username), Some(nip05)) => username.union(nip05), 381 (None, Some(username), None) => username, 382 _ => ui.add(Label::new(RichText::new(name.name()))), 383 }; 384 385 if add_placeholder_space { 386 ui.add_space(16.0); 387 } 388 389 resp 390 } 391 } 392 393 pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str { 394 unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture()))) 395 } 396 397 pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str { 398 if let Some(url) = maybe_url { 399 url 400 } else { 401 ProfilePic::no_pfp_url() 402 } 403 } 404 405 fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b 406 where 407 'b: 'a, 408 { 409 move |ui: &mut egui::Ui| { 410 if let Some(about) = profile.record().profile().and_then(|p| p.about()) { 411 let resp = ui.label(about); 412 ui.add_space(8.0); 413 resp 414 } else { 415 // need any Response so we dont need an Option 416 ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) 417 } 418 } 419 } 420 421 fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option<egui::load::SizedTexture> { 422 // TODO: cache banner 423 if !banner_url.is_empty() { 424 let texture_load_res = 425 egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size()); 426 if let Ok(texture_poll) = texture_load_res { 427 match texture_poll { 428 TexturePoll::Pending { .. } => {} 429 TexturePoll::Ready { texture, .. } => return Some(texture), 430 } 431 } 432 } 433 434 None 435 } 436 437 fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response { 438 ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| { 439 banner_url 440 .and_then(|url| banner_texture(ui, url)) 441 .map(|texture| { 442 images::aspect_fill( 443 ui, 444 Sense::hover(), 445 texture.id, 446 texture.size.x / texture.size.y, 447 ) 448 }) 449 .unwrap_or_else(|| ui.label("")) 450 }) 451 }