mod.rs (17313B)
1 pub mod edit; 2 3 pub use edit::EditProfileView; 4 use egui::{vec2, Color32, CornerRadius, Layout, Rect, RichText, ScrollArea, Sense, Stroke}; 5 use enostr::Pubkey; 6 use nostrdb::{ProfileRecord, Transaction}; 7 use notedeck::{tr, DragResponse, Localization, ProfileContext}; 8 use notedeck_ui::profile::{context::ProfileContextWidget, follow_button}; 9 use robius_open::Uri; 10 use tracing::error; 11 12 use crate::{ 13 timeline::{TimelineCache, TimelineKind}, 14 ui::timeline::{tabs_ui, TimelineTabView}, 15 }; 16 use notedeck::{ 17 name::get_display_name, profile::get_profile_url, IsFollowing, NoteAction, NoteContext, 18 NotedeckTextStyle, 19 }; 20 use notedeck_ui::{ 21 app_images, 22 profile::{about_section_widget, banner, display_name_widget}, 23 NoteOptions, ProfilePic, 24 }; 25 26 pub struct ProfileView<'a, 'd> { 27 pubkey: &'a Pubkey, 28 col_id: usize, 29 timeline_cache: &'a mut TimelineCache, 30 note_options: NoteOptions, 31 note_context: &'a mut NoteContext<'d>, 32 } 33 34 pub enum ProfileViewAction { 35 EditProfile, 36 Note(NoteAction), 37 Unfollow(Pubkey), 38 Follow(Pubkey), 39 Context(ProfileContext), 40 ShowFollowing(Pubkey), 41 ShowFollowers(Pubkey), 42 } 43 44 struct ProfileScrollResponse { 45 body_end_pos: f32, 46 action: Option<ProfileViewAction>, 47 } 48 49 impl<'a, 'd> ProfileView<'a, 'd> { 50 #[allow(clippy::too_many_arguments)] 51 pub fn new( 52 pubkey: &'a Pubkey, 53 col_id: usize, 54 timeline_cache: &'a mut TimelineCache, 55 note_options: NoteOptions, 56 note_context: &'a mut NoteContext<'d>, 57 ) -> Self { 58 ProfileView { 59 pubkey, 60 col_id, 61 timeline_cache, 62 note_options, 63 note_context, 64 } 65 } 66 67 pub fn scroll_id(col_id: usize, profile_pubkey: &Pubkey) -> egui::Id { 68 egui::Id::new(("profile_scroll", col_id, profile_pubkey)) 69 } 70 71 pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<ProfileViewAction> { 72 let scroll_id = ProfileView::scroll_id(self.col_id, self.pubkey); 73 let scroll_area = ScrollArea::vertical().id_salt(scroll_id).animated(false); 74 75 let Some(profile_timeline) = self 76 .timeline_cache 77 .get_mut(&TimelineKind::Profile(*self.pubkey)) 78 else { 79 return DragResponse::none(); 80 }; 81 82 let output = scroll_area.show(ui, |ui| { 83 let mut action = None; 84 let txn = Transaction::new(self.note_context.ndb).expect("txn"); 85 let profile = self 86 .note_context 87 .ndb 88 .get_profile_by_pubkey(&txn, self.pubkey.bytes()) 89 .ok(); 90 91 if let Some(profile_view_action) = 92 profile_body(ui, self.pubkey, self.note_context, profile.as_ref(), &txn) 93 { 94 action = Some(profile_view_action); 95 } 96 97 let tabs_resp = tabs_ui( 98 ui, 99 self.note_context.i18n, 100 profile_timeline.selected_view, 101 &profile_timeline.views, 102 ); 103 profile_timeline.selected_view = tabs_resp.inner; 104 105 let reversed = false; 106 // poll for new notes and insert them into our existing notes 107 if let Err(e) = profile_timeline.poll_notes_into_view( 108 self.note_context.ndb, 109 &txn, 110 self.note_context.unknown_ids, 111 self.note_context.note_cache, 112 reversed, 113 ) { 114 error!("Profile::poll_notes_into_view: {e}"); 115 } 116 117 if let Some(note_action) = TimelineTabView::new( 118 profile_timeline.current_view(), 119 self.note_options, 120 &txn, 121 self.note_context, 122 ) 123 .show(ui) 124 { 125 action = Some(ProfileViewAction::Note(note_action)); 126 } 127 128 ProfileScrollResponse { 129 body_end_pos: tabs_resp.response.rect.bottom(), 130 action, 131 } 132 }); 133 134 // only allow front insert when the profile body is fully obstructed 135 profile_timeline.enable_front_insert = output.inner.body_end_pos < ui.clip_rect().top(); 136 137 DragResponse::output(output.inner.action).scroll_raw(output.id) 138 } 139 } 140 141 fn profile_body( 142 ui: &mut egui::Ui, 143 pubkey: &Pubkey, 144 note_context: &mut NoteContext, 145 profile: Option<&ProfileRecord<'_>>, 146 txn: &Transaction, 147 ) -> Option<ProfileViewAction> { 148 let mut action = None; 149 ui.vertical(|ui| { 150 let banner_resp = banner( 151 ui, 152 profile 153 .map(|p| p.record().profile()) 154 .and_then(|p| p.and_then(|p| p.banner())), 155 120.0, 156 ); 157 158 let place_context = { 159 let mut rect = banner_resp.rect; 160 let size = 24.0; 161 rect.set_bottom(rect.top() + size); 162 rect.set_left(rect.right() - size); 163 rect.translate(vec2(-16.0, 16.0)) 164 }; 165 166 let context_resp = ProfileContextWidget::new(place_context).context_button(ui, pubkey); 167 if let Some(selection) = 168 ProfileContextWidget::context_menu(ui, note_context.i18n, context_resp) 169 { 170 action = Some(ProfileViewAction::Context(ProfileContext { 171 profile: *pubkey, 172 selection, 173 })); 174 } 175 176 let padding = 12.0; 177 notedeck_ui::padding(padding, ui, |ui| { 178 let mut pfp_rect = ui.available_rect_before_wrap(); 179 let size = 80.0; 180 pfp_rect.set_width(size); 181 pfp_rect.set_height(size); 182 let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); 183 184 ui.horizontal(|ui| { 185 ui.put( 186 pfp_rect, 187 &mut ProfilePic::new( 188 note_context.img_cache, 189 note_context.jobs, 190 get_profile_url(profile), 191 ) 192 .size(size) 193 .border(ProfilePic::border_stroke(ui)), 194 ); 195 196 if ui 197 .add(copy_key_widget(&pfp_rect, note_context.i18n)) 198 .clicked() 199 { 200 let to_copy = if let Some(bech) = pubkey.npub() { 201 bech 202 } else { 203 error!("Could not convert Pubkey to bech"); 204 String::new() 205 }; 206 ui.ctx().copy_text(to_copy) 207 } 208 209 ui.with_layout(Layout::right_to_left(egui::Align::RIGHT), |ui| { 210 ui.add_space(24.0); 211 212 let target_key = pubkey; 213 let selected = note_context.accounts.get_selected_account(); 214 215 let profile_type = if selected.key.secret_key.is_none() { 216 ProfileType::ReadOnly 217 } else if &selected.key.pubkey == pubkey { 218 ProfileType::MyProfile 219 } else { 220 ProfileType::Followable(selected.is_following(target_key.bytes())) 221 }; 222 223 match profile_type { 224 ProfileType::MyProfile => { 225 if ui.add(edit_profile_button(note_context.i18n)).clicked() { 226 action = Some(ProfileViewAction::EditProfile); 227 } 228 } 229 ProfileType::Followable(is_following) => { 230 let follow_button = ui.add(follow_button(is_following)); 231 232 if follow_button.clicked() { 233 action = match is_following { 234 IsFollowing::Unknown => { 235 // don't do anything, we don't have contact list 236 None 237 } 238 239 IsFollowing::Yes => { 240 Some(ProfileViewAction::Unfollow(target_key.to_owned())) 241 } 242 243 IsFollowing::No => { 244 Some(ProfileViewAction::Follow(target_key.to_owned())) 245 } 246 }; 247 } 248 } 249 ProfileType::ReadOnly => {} 250 } 251 }); 252 }); 253 254 ui.add_space(18.0); 255 256 ui.add(display_name_widget(&get_display_name(profile), false)); 257 258 ui.add_space(8.0); 259 260 ui.add(about_section_widget(profile)); 261 262 ui.add_space(8.0); 263 264 if let Some(stats_action) = profile_stats(ui, pubkey, note_context, txn) { 265 action = Some(stats_action); 266 } 267 268 ui.horizontal_wrapped(|ui| { 269 let website_url = profile 270 .as_ref() 271 .map(|p| p.record().profile()) 272 .and_then(|p| p.and_then(|p| p.website()).filter(|s| !s.is_empty())); 273 274 let lud16 = profile 275 .as_ref() 276 .map(|p| p.record().profile()) 277 .and_then(|p| p.and_then(|p| p.lud16()).filter(|s| !s.is_empty())); 278 279 if let Some(website_url) = website_url { 280 ui.horizontal_wrapped(|ui| { 281 handle_link(ui, website_url); 282 }); 283 } 284 285 if let Some(lud16) = lud16 { 286 if website_url.is_some() { 287 ui.end_row(); 288 } 289 ui.horizontal_wrapped(|ui| { 290 handle_lud16(ui, lud16); 291 }); 292 } 293 }); 294 }); 295 }); 296 297 action 298 } 299 300 enum ProfileType { 301 MyProfile, 302 ReadOnly, 303 Followable(IsFollowing), 304 } 305 306 fn profile_stats( 307 ui: &mut egui::Ui, 308 pubkey: &Pubkey, 309 note_context: &mut NoteContext, 310 txn: &Transaction, 311 ) -> Option<ProfileViewAction> { 312 let mut action = None; 313 314 let filter = nostrdb::Filter::new() 315 .authors([pubkey.bytes()]) 316 .kinds([3]) 317 .limit(1) 318 .build(); 319 320 let mut count = 0; 321 let following_count = { 322 if let Ok(results) = note_context.ndb.query(txn, &[filter], 1) { 323 if let Some(result) = results.first() { 324 for tag in result.note.tags() { 325 if tag.count() >= 2 { 326 if let Some("p") = tag.get_str(0) { 327 if tag.get_id(1).is_some() { 328 count += 1; 329 } 330 } 331 } 332 } 333 } 334 } 335 336 count 337 }; 338 339 ui.horizontal(|ui| { 340 let resp = ui 341 .label( 342 RichText::new(format!("{following_count} ")) 343 .size(notedeck::fonts::get_font_size( 344 ui.ctx(), 345 &NotedeckTextStyle::Small, 346 )) 347 .color(ui.visuals().text_color()), 348 ) 349 .on_hover_cursor(egui::CursorIcon::PointingHand); 350 351 let resp2 = ui 352 .label( 353 RichText::new(tr!( 354 note_context.i18n, 355 "following", 356 "Label for number of accounts being followed" 357 )) 358 .size(notedeck::fonts::get_font_size( 359 ui.ctx(), 360 &NotedeckTextStyle::Small, 361 )) 362 .color(ui.visuals().weak_text_color()), 363 ) 364 .on_hover_cursor(egui::CursorIcon::PointingHand); 365 366 if resp.clicked() || resp2.clicked() { 367 action = Some(ProfileViewAction::ShowFollowing(*pubkey)); 368 } 369 370 let selected = note_context.accounts.get_selected_account(); 371 if &selected.key.pubkey != pubkey 372 && selected.is_following(pubkey.bytes()) == notedeck::IsFollowing::Yes 373 { 374 ui.add_space(8.0); 375 ui.label( 376 RichText::new(tr!( 377 note_context.i18n, 378 "Follows you", 379 "Badge indicating user follows you" 380 )) 381 .size(notedeck::fonts::get_font_size( 382 ui.ctx(), 383 &NotedeckTextStyle::Tiny, 384 )) 385 .color(ui.visuals().weak_text_color()), 386 ); 387 } 388 }); 389 390 action 391 } 392 393 fn handle_link(ui: &mut egui::Ui, website_url: &str) { 394 let img = if ui.visuals().dark_mode { 395 app_images::link_dark_image() 396 } else { 397 app_images::link_light_image() 398 }; 399 400 ui.add(img); 401 if ui 402 .label(RichText::new(website_url).color(notedeck_ui::colors::PINK)) 403 .on_hover_cursor(egui::CursorIcon::PointingHand) 404 .on_hover_text(website_url) 405 .interact(Sense::click()) 406 .clicked() 407 { 408 if let Err(e) = Uri::new(website_url).open() { 409 error!("Failed to open URL {} because: {:?}", website_url, e); 410 }; 411 } 412 } 413 414 fn handle_lud16(ui: &mut egui::Ui, lud16: &str) { 415 ui.add(app_images::filled_zap_image()); 416 417 let _ = ui 418 .label(RichText::new(lud16).color(notedeck_ui::colors::PINK)) 419 .on_hover_text(lud16); 420 } 421 422 fn copy_key_widget<'a>( 423 pfp_rect: &'a egui::Rect, 424 i18n: &'a mut Localization, 425 ) -> impl egui::Widget + 'a { 426 |ui: &mut egui::Ui| -> egui::Response { 427 let painter = ui.painter(); 428 #[allow(deprecated)] 429 let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size( 430 pfp_rect.center_bottom(), 431 egui::vec2(48.0, 28.0), 432 )); 433 let resp = ui 434 .interact( 435 copy_key_rect, 436 ui.id().with("custom_painter"), 437 Sense::click(), 438 ) 439 .on_hover_text(tr!( 440 i18n, 441 "Copy npub to clipboard", 442 "Tooltip text for copying npub to clipboard" 443 )); 444 445 let copy_key_rounding = CornerRadius::same(100); 446 let fill_color = if resp.hovered() { 447 ui.visuals().widgets.inactive.weak_bg_fill 448 } else { 449 ui.visuals().noninteractive().bg_stroke.color 450 }; 451 painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color); 452 453 let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill; 454 painter.rect_stroke( 455 copy_key_rect.shrink(1.0), 456 copy_key_rounding, 457 Stroke::new(1.0, stroke_color), 458 egui::StrokeKind::Outside, 459 ); 460 461 app_images::key_image().paint_at( 462 ui, 463 #[allow(deprecated)] 464 painter.round_rect_to_pixels(egui::Rect::from_center_size( 465 copy_key_rect.center(), 466 egui::vec2(16.0, 16.0), 467 )), 468 ); 469 470 resp 471 } 472 } 473 474 fn edit_profile_button<'a>(i18n: &'a mut Localization) -> impl egui::Widget + 'a { 475 |ui: &mut egui::Ui| -> egui::Response { 476 let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click()); 477 let painter = ui.painter_at(rect); 478 #[allow(deprecated)] 479 let rect = painter.round_rect_to_pixels(rect); 480 481 painter.rect_filled( 482 rect, 483 CornerRadius::same(8), 484 if resp.hovered() { 485 ui.visuals().widgets.active.bg_fill 486 } else { 487 ui.visuals().widgets.inactive.bg_fill 488 }, 489 ); 490 painter.rect_stroke( 491 rect.shrink(1.0), 492 CornerRadius::same(8), 493 if resp.hovered() { 494 ui.visuals().widgets.active.bg_stroke 495 } else { 496 ui.visuals().widgets.inactive.bg_stroke 497 }, 498 egui::StrokeKind::Outside, 499 ); 500 501 let edit_icon_size = vec2(16.0, 16.0); 502 let galley = painter.layout( 503 tr!(i18n, "Edit Profile", "Button label to edit user profile"), 504 NotedeckTextStyle::Button.get_font_id(ui.ctx()), 505 ui.visuals().text_color(), 506 rect.width(), 507 ); 508 509 let space_between_icon_galley = 8.0; 510 let half_icon_size = edit_icon_size.x / 2.0; 511 let galley_rect = { 512 let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size()); 513 galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0)) 514 }; 515 516 let edit_icon_rect = { 517 let mut center = galley_rect.left_center(); 518 center.x -= half_icon_size + space_between_icon_galley; 519 #[allow(deprecated)] 520 painter.round_rect_to_pixels(Rect::from_center_size( 521 painter.round_pos_to_pixel_center(center), 522 edit_icon_size, 523 )) 524 }; 525 526 painter.galley(galley_rect.left_top(), galley, Color32::WHITE); 527 528 app_images::edit_dark_image() 529 .tint(ui.visuals().text_color()) 530 .paint_at(ui, edit_icon_rect); 531 532 resp 533 } 534 }