mod.rs (13644B)
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, Localization}; 8 use notedeck_ui::profile::follow_button; 9 use tracing::error; 10 11 use crate::{ 12 timeline::{TimelineCache, TimelineKind}, 13 ui::timeline::{tabs_ui, TimelineTabView}, 14 }; 15 use notedeck::{ 16 name::get_display_name, profile::get_profile_url, IsFollowing, NoteAction, NoteContext, 17 NotedeckTextStyle, 18 }; 19 use notedeck_ui::{ 20 app_images, 21 jobs::JobsCache, 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 jobs: &'a mut JobsCache, 33 } 34 35 pub enum ProfileViewAction { 36 EditProfile, 37 Note(NoteAction), 38 Unfollow(Pubkey), 39 Follow(Pubkey), 40 } 41 42 impl<'a, 'd> ProfileView<'a, 'd> { 43 #[allow(clippy::too_many_arguments)] 44 pub fn new( 45 pubkey: &'a Pubkey, 46 col_id: usize, 47 timeline_cache: &'a mut TimelineCache, 48 note_options: NoteOptions, 49 note_context: &'a mut NoteContext<'d>, 50 jobs: &'a mut JobsCache, 51 ) -> Self { 52 ProfileView { 53 pubkey, 54 col_id, 55 timeline_cache, 56 note_options, 57 note_context, 58 jobs, 59 } 60 } 61 62 pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<ProfileViewAction> { 63 let scroll_id = egui::Id::new(("profile_scroll", self.col_id, self.pubkey)); 64 let offset_id = scroll_id.with("scroll_offset"); 65 66 let mut scroll_area = ScrollArea::vertical().id_salt(scroll_id); 67 68 if let Some(offset) = ui.data(|i| i.get_temp::<f32>(offset_id)) { 69 scroll_area = scroll_area.vertical_scroll_offset(offset); 70 } 71 72 let output = scroll_area.show(ui, |ui| { 73 let mut action = None; 74 let txn = Transaction::new(self.note_context.ndb).expect("txn"); 75 let profile = self 76 .note_context 77 .ndb 78 .get_profile_by_pubkey(&txn, self.pubkey.bytes()) 79 .ok(); 80 81 if let Some(profile_view_action) = self.profile_body(ui, profile.as_ref()) { 82 action = Some(profile_view_action); 83 } 84 let profile_timeline = self 85 .timeline_cache 86 .notes( 87 self.note_context.ndb, 88 self.note_context.note_cache, 89 &txn, 90 &TimelineKind::Profile(*self.pubkey), 91 ) 92 .get_ptr(); 93 94 profile_timeline.selected_view = tabs_ui( 95 ui, 96 self.note_context.i18n, 97 profile_timeline.selected_view, 98 &profile_timeline.views, 99 ); 100 101 let reversed = false; 102 // poll for new notes and insert them into our existing notes 103 if let Err(e) = profile_timeline.poll_notes_into_view( 104 self.note_context.ndb, 105 &txn, 106 self.note_context.unknown_ids, 107 self.note_context.note_cache, 108 reversed, 109 ) { 110 error!("Profile::poll_notes_into_view: {e}"); 111 } 112 113 if let Some(note_action) = TimelineTabView::new( 114 profile_timeline.current_view(), 115 reversed, 116 self.note_options, 117 &txn, 118 self.note_context, 119 self.jobs, 120 ) 121 .show(ui) 122 { 123 action = Some(ProfileViewAction::Note(note_action)); 124 } 125 126 action 127 }); 128 129 ui.data_mut(|d| d.insert_temp(offset_id, output.state.offset.y)); 130 131 output.inner 132 } 133 134 fn profile_body( 135 &mut self, 136 ui: &mut egui::Ui, 137 profile: Option<&ProfileRecord<'_>>, 138 ) -> Option<ProfileViewAction> { 139 let mut action = None; 140 ui.vertical(|ui| { 141 banner( 142 ui, 143 profile 144 .map(|p| p.record().profile()) 145 .and_then(|p| p.and_then(|p| p.banner())), 146 120.0, 147 ); 148 149 let padding = 12.0; 150 notedeck_ui::padding(padding, ui, |ui| { 151 let mut pfp_rect = ui.available_rect_before_wrap(); 152 let size = 80.0; 153 pfp_rect.set_width(size); 154 pfp_rect.set_height(size); 155 let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); 156 157 ui.horizontal(|ui| { 158 ui.put( 159 pfp_rect, 160 &mut ProfilePic::new(self.note_context.img_cache, get_profile_url(profile)) 161 .size(size) 162 .border(ProfilePic::border_stroke(ui)), 163 ); 164 165 if ui.add(copy_key_widget(&pfp_rect)).clicked() { 166 let to_copy = if let Some(bech) = self.pubkey.npub() { 167 bech 168 } else { 169 error!("Could not convert Pubkey to bech"); 170 String::new() 171 }; 172 ui.ctx().copy_text(to_copy) 173 } 174 175 ui.with_layout(Layout::right_to_left(egui::Align::RIGHT), |ui| { 176 ui.add_space(24.0); 177 178 let target_key = self.pubkey; 179 let selected = self.note_context.accounts.get_selected_account(); 180 181 let profile_type = if selected.key.secret_key.is_none() { 182 ProfileType::ReadOnly 183 } else if &selected.key.pubkey == self.pubkey { 184 ProfileType::MyProfile 185 } else { 186 ProfileType::Followable(selected.is_following(target_key.bytes())) 187 }; 188 189 match profile_type { 190 ProfileType::MyProfile => { 191 if ui 192 .add(edit_profile_button(self.note_context.i18n)) 193 .clicked() 194 { 195 action = Some(ProfileViewAction::EditProfile); 196 } 197 } 198 ProfileType::Followable(is_following) => { 199 let follow_button = ui.add(follow_button(is_following)); 200 201 if follow_button.clicked() { 202 action = match is_following { 203 IsFollowing::Unknown => { 204 // don't do anything, we don't have contact list 205 None 206 } 207 208 IsFollowing::Yes => { 209 Some(ProfileViewAction::Unfollow(target_key.to_owned())) 210 } 211 212 IsFollowing::No => { 213 Some(ProfileViewAction::Follow(target_key.to_owned())) 214 } 215 }; 216 } 217 } 218 ProfileType::ReadOnly => {} 219 } 220 }); 221 }); 222 223 ui.add_space(18.0); 224 225 ui.add(display_name_widget(&get_display_name(profile), false)); 226 227 ui.add_space(8.0); 228 229 ui.add(about_section_widget(profile)); 230 231 ui.horizontal_wrapped(|ui| { 232 let website_url = profile 233 .as_ref() 234 .map(|p| p.record().profile()) 235 .and_then(|p| p.and_then(|p| p.website()).filter(|s| !s.is_empty())); 236 237 let lud16 = profile 238 .as_ref() 239 .map(|p| p.record().profile()) 240 .and_then(|p| p.and_then(|p| p.lud16()).filter(|s| !s.is_empty())); 241 242 if let Some(website_url) = website_url { 243 ui.horizontal(|ui| { 244 handle_link(ui, website_url); 245 }); 246 } 247 248 if let Some(lud16) = lud16 { 249 if website_url.is_some() { 250 ui.end_row(); 251 } 252 ui.horizontal(|ui| { 253 handle_lud16(ui, lud16); 254 }); 255 } 256 }); 257 }); 258 }); 259 260 action 261 } 262 } 263 264 enum ProfileType { 265 MyProfile, 266 ReadOnly, 267 Followable(IsFollowing), 268 } 269 270 fn handle_link(ui: &mut egui::Ui, website_url: &str) { 271 let img = if ui.visuals().dark_mode { 272 app_images::link_dark_image() 273 } else { 274 app_images::link_light_image() 275 }; 276 277 ui.add(img); 278 if ui 279 .label(RichText::new(website_url).color(notedeck_ui::colors::PINK)) 280 .on_hover_cursor(egui::CursorIcon::PointingHand) 281 .on_hover_text(website_url) 282 .interact(Sense::click()) 283 .clicked() 284 { 285 if let Err(e) = open::that(website_url) { 286 error!("Failed to open URL {} because: {}", website_url, e); 287 }; 288 } 289 } 290 291 fn handle_lud16(ui: &mut egui::Ui, lud16: &str) { 292 ui.add(app_images::filled_zap_image()); 293 294 let _ = ui 295 .label(RichText::new(lud16).color(notedeck_ui::colors::PINK)) 296 .on_hover_text(lud16); 297 } 298 299 fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ { 300 |ui: &mut egui::Ui| -> egui::Response { 301 let painter = ui.painter(); 302 #[allow(deprecated)] 303 let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size( 304 pfp_rect.center_bottom(), 305 egui::vec2(48.0, 28.0), 306 )); 307 let resp = ui 308 .interact( 309 copy_key_rect, 310 ui.id().with("custom_painter"), 311 Sense::click(), 312 ) 313 .on_hover_text("Copy npub to clipboard"); 314 315 let copy_key_rounding = CornerRadius::same(100); 316 let fill_color = if resp.hovered() { 317 ui.visuals().widgets.inactive.weak_bg_fill 318 } else { 319 ui.visuals().noninteractive().bg_stroke.color 320 }; 321 painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color); 322 323 let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill; 324 painter.rect_stroke( 325 copy_key_rect.shrink(1.0), 326 copy_key_rounding, 327 Stroke::new(1.0, stroke_color), 328 egui::StrokeKind::Outside, 329 ); 330 331 app_images::key_image().paint_at( 332 ui, 333 #[allow(deprecated)] 334 painter.round_rect_to_pixels(egui::Rect::from_center_size( 335 copy_key_rect.center(), 336 egui::vec2(16.0, 16.0), 337 )), 338 ); 339 340 resp 341 } 342 } 343 344 fn edit_profile_button<'a>(i18n: &'a mut Localization) -> impl egui::Widget + 'a { 345 |ui: &mut egui::Ui| -> egui::Response { 346 let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click()); 347 let painter = ui.painter_at(rect); 348 #[allow(deprecated)] 349 let rect = painter.round_rect_to_pixels(rect); 350 351 painter.rect_filled( 352 rect, 353 CornerRadius::same(8), 354 if resp.hovered() { 355 ui.visuals().widgets.active.bg_fill 356 } else { 357 ui.visuals().widgets.inactive.bg_fill 358 }, 359 ); 360 painter.rect_stroke( 361 rect.shrink(1.0), 362 CornerRadius::same(8), 363 if resp.hovered() { 364 ui.visuals().widgets.active.bg_stroke 365 } else { 366 ui.visuals().widgets.inactive.bg_stroke 367 }, 368 egui::StrokeKind::Outside, 369 ); 370 371 let edit_icon_size = vec2(16.0, 16.0); 372 let galley = painter.layout( 373 tr!(i18n, "Edit Profile", "Button label to edit user profile"), 374 NotedeckTextStyle::Button.get_font_id(ui.ctx()), 375 ui.visuals().text_color(), 376 rect.width(), 377 ); 378 379 let space_between_icon_galley = 8.0; 380 let half_icon_size = edit_icon_size.x / 2.0; 381 let galley_rect = { 382 let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size()); 383 galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0)) 384 }; 385 386 let edit_icon_rect = { 387 let mut center = galley_rect.left_center(); 388 center.x -= half_icon_size + space_between_icon_galley; 389 #[allow(deprecated)] 390 painter.round_rect_to_pixels(Rect::from_center_size( 391 painter.round_pos_to_pixel_center(center), 392 edit_icon_size, 393 )) 394 }; 395 396 painter.galley(galley_rect.left_top(), galley, Color32::WHITE); 397 398 app_images::edit_dark_image() 399 .tint(ui.visuals().text_color()) 400 .paint_at(ui, edit_icon_rect); 401 402 resp 403 } 404 }