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