mod.rs (18246B)
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.accounts.selected_account_pubkey(), 109 self.note_context.ndb, 110 &txn, 111 self.note_context.unknown_ids, 112 self.note_context.note_cache, 113 reversed, 114 ) { 115 error!("Profile::poll_notes_into_view: {e}"); 116 } 117 118 if let Some(note_action) = TimelineTabView::new( 119 profile_timeline.current_view(), 120 self.note_options, 121 &txn, 122 self.note_context, 123 ) 124 .show(ui) 125 { 126 action = Some(ProfileViewAction::Note(note_action)); 127 } 128 129 ProfileScrollResponse { 130 body_end_pos: tabs_resp.response.rect.bottom(), 131 action, 132 } 133 }); 134 135 // only allow front insert when the profile body is fully obstructed 136 profile_timeline.enable_front_insert = output.inner.body_end_pos < ui.clip_rect().top(); 137 138 DragResponse::output(output.inner.action).scroll_raw(output.id) 139 } 140 } 141 142 fn profile_body( 143 ui: &mut egui::Ui, 144 pubkey: &Pubkey, 145 note_context: &mut NoteContext, 146 profile: Option<&ProfileRecord<'_>>, 147 txn: &Transaction, 148 ) -> Option<ProfileViewAction> { 149 let mut action = None; 150 ui.vertical(|ui| { 151 let banner_resp = banner( 152 ui, 153 note_context.img_cache, 154 note_context.jobs, 155 profile 156 .map(|p| p.record().profile()) 157 .and_then(|p| p.and_then(|p| p.banner())), 158 120.0, 159 ); 160 161 let place_context = { 162 let mut rect = banner_resp.rect; 163 let size = 24.0; 164 rect.set_bottom(rect.top() + size); 165 rect.set_left(rect.right() - size); 166 rect.translate(vec2(-16.0, 16.0)) 167 }; 168 169 let context_resp = ProfileContextWidget::new(place_context).context_button(ui, pubkey); 170 let can_sign = note_context 171 .accounts 172 .get_selected_account() 173 .key 174 .secret_key 175 .is_some(); 176 let is_muted = note_context.accounts.mute().is_pk_muted(pubkey.bytes()); 177 if let Some(selection) = ProfileContextWidget::context_menu( 178 ui, 179 note_context.i18n, 180 context_resp, 181 can_sign, 182 is_muted, 183 ) { 184 action = Some(ProfileViewAction::Context(ProfileContext { 185 profile: *pubkey, 186 selection, 187 })); 188 } 189 190 let padding = 12.0; 191 notedeck_ui::padding(padding, ui, |ui| { 192 let mut pfp_rect = ui.available_rect_before_wrap(); 193 let size = 80.0; 194 pfp_rect.set_width(size); 195 pfp_rect.set_height(size); 196 let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); 197 198 ui.horizontal(|ui| { 199 ui.put( 200 pfp_rect, 201 &mut ProfilePic::new( 202 note_context.img_cache, 203 note_context.jobs, 204 get_profile_url(profile), 205 ) 206 .size(size) 207 .border(ProfilePic::border_stroke(ui)), 208 ); 209 210 if ui 211 .add(copy_key_widget(&pfp_rect, note_context.i18n)) 212 .clicked() 213 { 214 let to_copy = if let Some(bech) = pubkey.npub() { 215 bech 216 } else { 217 error!("Could not convert Pubkey to bech"); 218 String::new() 219 }; 220 ui.ctx().copy_text(to_copy) 221 } 222 223 ui.with_layout(Layout::right_to_left(egui::Align::RIGHT), |ui| { 224 ui.add_space(24.0); 225 226 let target_key = pubkey; 227 let selected = note_context.accounts.get_selected_account(); 228 229 let profile_type = if selected.key.secret_key.is_none() { 230 ProfileType::ReadOnly 231 } else if &selected.key.pubkey == pubkey { 232 ProfileType::MyProfile 233 } else { 234 ProfileType::Followable(selected.is_following(target_key.bytes())) 235 }; 236 237 match profile_type { 238 ProfileType::MyProfile => { 239 if ui.add(edit_profile_button(note_context.i18n)).clicked() { 240 action = Some(ProfileViewAction::EditProfile); 241 } 242 } 243 ProfileType::Followable(is_following) => { 244 let follow_button = ui.add(follow_button(is_following)); 245 246 if follow_button.clicked() { 247 action = match is_following { 248 IsFollowing::Unknown => { 249 // don't do anything, we don't have contact list 250 None 251 } 252 253 IsFollowing::Yes => { 254 Some(ProfileViewAction::Unfollow(target_key.to_owned())) 255 } 256 257 IsFollowing::No => { 258 Some(ProfileViewAction::Follow(target_key.to_owned())) 259 } 260 }; 261 } 262 } 263 ProfileType::ReadOnly => {} 264 } 265 }); 266 }); 267 268 ui.add_space(18.0); 269 270 let mut name = get_display_name(profile); 271 if let Some(raw_nip05) = profile 272 .and_then(|p| p.record().profile()) 273 .and_then(|p| p.nip05()) 274 { 275 note_context 276 .nip05_cache 277 .request_validation(*pubkey, raw_nip05); 278 if note_context.nip05_cache.status(pubkey) == Some(¬edeck::Nip05Status::Valid) { 279 name.nip05_valid = true; 280 } 281 } 282 ui.add(display_name_widget(&name, false)); 283 284 ui.add_space(8.0); 285 286 ui.add(about_section_widget(profile)); 287 288 ui.add_space(8.0); 289 290 if let Some(stats_action) = profile_stats(ui, pubkey, note_context, txn) { 291 action = Some(stats_action); 292 } 293 294 ui.horizontal_wrapped(|ui| { 295 let website_url = profile 296 .as_ref() 297 .map(|p| p.record().profile()) 298 .and_then(|p| p.and_then(|p| p.website()).filter(|s| !s.is_empty())); 299 300 let lud16 = profile 301 .as_ref() 302 .map(|p| p.record().profile()) 303 .and_then(|p| p.and_then(|p| p.lud16()).filter(|s| !s.is_empty())); 304 305 if let Some(website_url) = website_url { 306 ui.horizontal_wrapped(|ui| { 307 handle_link(ui, website_url); 308 }); 309 } 310 311 if let Some(lud16) = lud16 { 312 if website_url.is_some() { 313 ui.end_row(); 314 } 315 ui.horizontal_wrapped(|ui| { 316 handle_lud16(ui, lud16); 317 }); 318 } 319 }); 320 }); 321 }); 322 323 action 324 } 325 326 enum ProfileType { 327 MyProfile, 328 ReadOnly, 329 Followable(IsFollowing), 330 } 331 332 fn profile_stats( 333 ui: &mut egui::Ui, 334 pubkey: &Pubkey, 335 note_context: &mut NoteContext, 336 txn: &Transaction, 337 ) -> Option<ProfileViewAction> { 338 let mut action = None; 339 340 let filter = nostrdb::Filter::new() 341 .authors([pubkey.bytes()]) 342 .kinds([3]) 343 .limit(1) 344 .build(); 345 346 let mut count = 0; 347 let following_count = { 348 if let Ok(results) = note_context.ndb.query(txn, &[filter], 1) { 349 if let Some(result) = results.first() { 350 for tag in result.note.tags() { 351 if tag.count() >= 2 { 352 if let Some("p") = tag.get_str(0) { 353 if tag.get_id(1).is_some() { 354 count += 1; 355 } 356 } 357 } 358 } 359 } 360 } 361 362 count 363 }; 364 365 ui.horizontal(|ui| { 366 let resp = ui 367 .label( 368 RichText::new(format!("{following_count} ")) 369 .size(notedeck::fonts::get_font_size( 370 ui.ctx(), 371 &NotedeckTextStyle::Small, 372 )) 373 .color(ui.visuals().text_color()), 374 ) 375 .on_hover_cursor(egui::CursorIcon::PointingHand); 376 377 let resp2 = ui 378 .label( 379 RichText::new(tr!( 380 note_context.i18n, 381 "following", 382 "Label for number of accounts being followed" 383 )) 384 .size(notedeck::fonts::get_font_size( 385 ui.ctx(), 386 &NotedeckTextStyle::Small, 387 )) 388 .color(ui.visuals().weak_text_color()), 389 ) 390 .on_hover_cursor(egui::CursorIcon::PointingHand); 391 392 if resp.clicked() || resp2.clicked() { 393 action = Some(ProfileViewAction::ShowFollowing(*pubkey)); 394 } 395 396 let selected = note_context.accounts.get_selected_account(); 397 if &selected.key.pubkey != pubkey 398 && selected.is_following(pubkey.bytes()) == notedeck::IsFollowing::Yes 399 { 400 ui.add_space(8.0); 401 ui.label( 402 RichText::new(tr!( 403 note_context.i18n, 404 "Follows you", 405 "Badge indicating user follows you" 406 )) 407 .size(notedeck::fonts::get_font_size( 408 ui.ctx(), 409 &NotedeckTextStyle::Tiny, 410 )) 411 .color(ui.visuals().weak_text_color()), 412 ); 413 } 414 }); 415 416 action 417 } 418 419 fn handle_link(ui: &mut egui::Ui, website_url: &str) { 420 let img = if ui.visuals().dark_mode { 421 app_images::link_dark_image() 422 } else { 423 app_images::link_light_image() 424 }; 425 426 ui.add(img); 427 if ui 428 .label(RichText::new(website_url).color(notedeck_ui::colors::PINK)) 429 .on_hover_cursor(egui::CursorIcon::PointingHand) 430 .on_hover_text(website_url) 431 .interact(Sense::click()) 432 .clicked() 433 { 434 if let Err(e) = Uri::new(website_url).open() { 435 error!("Failed to open URL {} because: {:?}", website_url, e); 436 }; 437 } 438 } 439 440 fn handle_lud16(ui: &mut egui::Ui, lud16: &str) { 441 ui.add(app_images::filled_zap_image()); 442 443 let _ = ui 444 .label(RichText::new(lud16).color(notedeck_ui::colors::PINK)) 445 .on_hover_text(lud16); 446 } 447 448 fn copy_key_widget<'a>( 449 pfp_rect: &'a egui::Rect, 450 i18n: &'a mut Localization, 451 ) -> impl egui::Widget + 'a { 452 |ui: &mut egui::Ui| -> egui::Response { 453 let painter = ui.painter(); 454 #[allow(deprecated)] 455 let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size( 456 pfp_rect.center_bottom(), 457 egui::vec2(48.0, 28.0), 458 )); 459 let resp = ui 460 .interact( 461 copy_key_rect, 462 ui.id().with("custom_painter"), 463 Sense::click(), 464 ) 465 .on_hover_text(tr!( 466 i18n, 467 "Copy npub to clipboard", 468 "Tooltip text for copying npub to clipboard" 469 )); 470 471 let copy_key_rounding = CornerRadius::same(100); 472 let fill_color = if resp.hovered() { 473 ui.visuals().widgets.inactive.weak_bg_fill 474 } else { 475 ui.visuals().noninteractive().bg_stroke.color 476 }; 477 painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color); 478 479 let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill; 480 painter.rect_stroke( 481 copy_key_rect.shrink(1.0), 482 copy_key_rounding, 483 Stroke::new(1.0, stroke_color), 484 egui::StrokeKind::Outside, 485 ); 486 487 app_images::key_image().paint_at( 488 ui, 489 #[allow(deprecated)] 490 painter.round_rect_to_pixels(egui::Rect::from_center_size( 491 copy_key_rect.center(), 492 egui::vec2(16.0, 16.0), 493 )), 494 ); 495 496 resp 497 } 498 } 499 500 fn edit_profile_button<'a>(i18n: &'a mut Localization) -> impl egui::Widget + 'a { 501 |ui: &mut egui::Ui| -> egui::Response { 502 let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click()); 503 let painter = ui.painter_at(rect); 504 #[allow(deprecated)] 505 let rect = painter.round_rect_to_pixels(rect); 506 507 painter.rect_filled( 508 rect, 509 CornerRadius::same(8), 510 if resp.hovered() { 511 ui.visuals().widgets.active.bg_fill 512 } else { 513 ui.visuals().widgets.inactive.bg_fill 514 }, 515 ); 516 painter.rect_stroke( 517 rect.shrink(1.0), 518 CornerRadius::same(8), 519 if resp.hovered() { 520 ui.visuals().widgets.active.bg_stroke 521 } else { 522 ui.visuals().widgets.inactive.bg_stroke 523 }, 524 egui::StrokeKind::Outside, 525 ); 526 527 let edit_icon_size = vec2(16.0, 16.0); 528 let galley = painter.layout( 529 tr!(i18n, "Edit Profile", "Button label to edit user profile"), 530 NotedeckTextStyle::Button.get_font_id(ui.ctx()), 531 ui.visuals().text_color(), 532 rect.width(), 533 ); 534 535 let space_between_icon_galley = 8.0; 536 let half_icon_size = edit_icon_size.x / 2.0; 537 let galley_rect = { 538 let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size()); 539 galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0)) 540 }; 541 542 let edit_icon_rect = { 543 let mut center = galley_rect.left_center(); 544 center.x -= half_icon_size + space_between_icon_galley; 545 #[allow(deprecated)] 546 painter.round_rect_to_pixels(Rect::from_center_size( 547 painter.round_pos_to_pixel_center(center), 548 edit_icon_size, 549 )) 550 }; 551 552 painter.galley(galley_rect.left_top(), galley, Color32::WHITE); 553 554 app_images::edit_dark_image() 555 .tint(ui.visuals().text_color()) 556 .paint_at(ui, edit_icon_rect); 557 558 resp 559 } 560 }