add_column.rs (21072B)
1 use core::f32; 2 use std::collections::HashMap; 3 4 use egui::{ 5 pos2, vec2, Align, Button, Color32, FontId, Id, ImageSource, Margin, Pos2, Rect, RichText, 6 Separator, Ui, Vec2, Widget, 7 }; 8 use enostr::Pubkey; 9 use nostrdb::{Ndb, Transaction}; 10 11 use crate::{ 12 login_manager::AcquireKeyState, 13 timeline::{PubkeySource, Timeline, TimelineKind}, 14 ui::anim::ICON_EXPANSION_MULTIPLE, 15 Damus, 16 }; 17 18 use notedeck::{AppContext, ImageCache, NotedeckTextStyle, UserAccount}; 19 20 use super::{anim::AnimationHelper, padding, ProfilePreview}; 21 22 pub enum AddColumnResponse { 23 Timeline(Timeline), 24 UndecidedNotification, 25 ExternalNotification, 26 Hashtag, 27 UndecidedIndividual, 28 ExternalIndividual, 29 } 30 31 pub enum NotificationColumnType { 32 Home, 33 External, 34 } 35 36 #[derive(Clone, Debug)] 37 enum AddColumnOption { 38 Universe, 39 UndecidedNotification, 40 ExternalNotification, 41 Notification(PubkeySource), 42 Home(PubkeySource), 43 UndecidedHashtag, 44 Hashtag(String), 45 UndecidedIndividual, 46 ExternalIndividual, 47 Individual(PubkeySource), 48 } 49 50 #[derive(Clone, Copy, Eq, PartialEq, Debug)] 51 pub enum AddColumnRoute { 52 Base, 53 UndecidedNotification, 54 ExternalNotification, 55 Hashtag, 56 UndecidedIndividual, 57 ExternalIndividual, 58 } 59 60 impl AddColumnOption { 61 pub fn take_as_response( 62 self, 63 ndb: &Ndb, 64 cur_account: Option<&UserAccount>, 65 ) -> Option<AddColumnResponse> { 66 match self { 67 AddColumnOption::Universe => TimelineKind::Universe 68 .into_timeline(ndb, None) 69 .map(AddColumnResponse::Timeline), 70 AddColumnOption::Notification(pubkey) => TimelineKind::Notifications(pubkey) 71 .into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) 72 .map(AddColumnResponse::Timeline), 73 AddColumnOption::UndecidedNotification => { 74 Some(AddColumnResponse::UndecidedNotification) 75 } 76 AddColumnOption::Home(pubkey) => { 77 let tlk = TimelineKind::contact_list(pubkey); 78 tlk.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) 79 .map(AddColumnResponse::Timeline) 80 } 81 AddColumnOption::ExternalNotification => Some(AddColumnResponse::ExternalNotification), 82 AddColumnOption::UndecidedHashtag => Some(AddColumnResponse::Hashtag), 83 AddColumnOption::Hashtag(hashtag) => TimelineKind::Hashtag(hashtag) 84 .into_timeline(ndb, None) 85 .map(AddColumnResponse::Timeline), 86 AddColumnOption::UndecidedIndividual => Some(AddColumnResponse::UndecidedIndividual), 87 AddColumnOption::ExternalIndividual => Some(AddColumnResponse::ExternalIndividual), 88 AddColumnOption::Individual(pubkey_source) => { 89 let tlk = TimelineKind::profile(pubkey_source); 90 tlk.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) 91 .map(AddColumnResponse::Timeline) 92 } 93 } 94 } 95 } 96 97 pub struct AddColumnView<'a> { 98 key_state_map: &'a mut HashMap<Id, AcquireKeyState>, 99 ndb: &'a Ndb, 100 img_cache: &'a mut ImageCache, 101 cur_account: Option<&'a UserAccount>, 102 } 103 104 impl<'a> AddColumnView<'a> { 105 pub fn new( 106 key_state_map: &'a mut HashMap<Id, AcquireKeyState>, 107 ndb: &'a Ndb, 108 img_cache: &'a mut ImageCache, 109 cur_account: Option<&'a UserAccount>, 110 ) -> Self { 111 Self { 112 key_state_map, 113 ndb, 114 img_cache, 115 cur_account, 116 } 117 } 118 119 pub fn ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 120 let mut selected_option: Option<AddColumnResponse> = None; 121 for column_option_data in self.get_base_options() { 122 let option = column_option_data.option.clone(); 123 if self.column_option_ui(ui, column_option_data).clicked() { 124 selected_option = option.take_as_response(self.ndb, self.cur_account); 125 } 126 127 ui.add(Separator::default().spacing(0.0)); 128 } 129 130 selected_option 131 } 132 133 fn notifications_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 134 let mut selected_option: Option<AddColumnResponse> = None; 135 for column_option_data in self.get_notifications_options() { 136 let option = column_option_data.option.clone(); 137 if self.column_option_ui(ui, column_option_data).clicked() { 138 selected_option = option.take_as_response(self.ndb, self.cur_account); 139 } 140 141 ui.add(Separator::default().spacing(0.0)); 142 } 143 144 selected_option 145 } 146 147 fn external_notification_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 148 let id = ui.id().with("external_notif"); 149 self.external_ui(ui, id, |pubkey| { 150 AddColumnOption::Notification(PubkeySource::Explicit(pubkey)) 151 }) 152 } 153 154 fn individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 155 let mut selected_option: Option<AddColumnResponse> = None; 156 for column_option_data in self.get_individual_options() { 157 let option = column_option_data.option.clone(); 158 if self.column_option_ui(ui, column_option_data).clicked() { 159 selected_option = option.take_as_response(self.ndb, self.cur_account); 160 } 161 162 ui.add(Separator::default().spacing(0.0)); 163 } 164 165 selected_option 166 } 167 168 fn external_individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 169 let id = ui.id().with("external_individual"); 170 171 self.external_ui(ui, id, |pubkey| { 172 AddColumnOption::Individual(PubkeySource::Explicit(pubkey)) 173 }) 174 } 175 176 fn external_ui( 177 &mut self, 178 ui: &mut Ui, 179 id: egui::Id, 180 to_option: fn(Pubkey) -> AddColumnOption, 181 ) -> Option<AddColumnResponse> { 182 padding(16.0, ui, |ui| { 183 let key_state = self.key_state_map.entry(id).or_default(); 184 185 let text_edit = key_state.get_acquire_textedit(|text| { 186 egui::TextEdit::singleline(text) 187 .hint_text( 188 RichText::new("Enter the user's key (npub, hex, nip05) here...") 189 .text_style(NotedeckTextStyle::Body.text_style()), 190 ) 191 .vertical_align(Align::Center) 192 .desired_width(f32::INFINITY) 193 .min_size(Vec2::new(0.0, 40.0)) 194 .margin(Margin::same(12.0)) 195 }); 196 197 ui.add(text_edit); 198 199 key_state.handle_input_change_after_acquire(); 200 key_state.loading_and_error_ui(ui); 201 202 if key_state.get_login_keypair().is_none() && ui.add(find_user_button()).clicked() { 203 key_state.apply_acquire(); 204 } 205 206 let resp = if let Some(keypair) = key_state.get_login_keypair() { 207 let txn = Transaction::new(self.ndb).expect("txn"); 208 if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, keypair.pubkey.bytes()) { 209 egui::Frame::window(ui.style()) 210 .outer_margin(Margin { 211 left: 4.0, 212 right: 4.0, 213 top: 12.0, 214 bottom: 32.0, 215 }) 216 .show(ui, |ui| { 217 ProfilePreview::new(&profile, self.img_cache).ui(ui); 218 }); 219 } 220 221 if ui.add(add_column_button()).clicked() { 222 to_option(keypair.pubkey).take_as_response(self.ndb, self.cur_account) 223 } else { 224 None 225 } 226 } else { 227 None 228 }; 229 if resp.is_some() { 230 self.key_state_map.remove(&id); 231 }; 232 resp 233 }) 234 .inner 235 } 236 237 fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response { 238 let icon_padding = 8.0; 239 let min_icon_width = 32.0; 240 let height_padding = 12.0; 241 let max_width = ui.available_width(); 242 let title_style = NotedeckTextStyle::Body; 243 let desc_style = NotedeckTextStyle::Button; 244 let title_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &title_style); 245 let desc_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &desc_style); 246 247 let max_height = { 248 let max_wrap_width = 249 max_width - ((icon_padding * 2.0) + (min_icon_width * ICON_EXPANSION_MULTIPLE)); 250 let title_max_font = FontId::new( 251 title_min_font_size * ICON_EXPANSION_MULTIPLE, 252 title_style.font_family(), 253 ); 254 let desc_max_font = FontId::new( 255 desc_min_font_size * ICON_EXPANSION_MULTIPLE, 256 desc_style.font_family(), 257 ); 258 let max_desc_galley = ui.fonts(|f| { 259 f.layout( 260 data.description.to_string(), 261 desc_max_font, 262 Color32::WHITE, 263 max_wrap_width, 264 ) 265 }); 266 267 let max_title_galley = ui.fonts(|f| { 268 f.layout( 269 data.title.to_string(), 270 title_max_font, 271 Color32::WHITE, 272 max_wrap_width, 273 ) 274 }); 275 276 let desc_font_max_size = max_desc_galley.rect.height(); 277 let title_font_max_size = max_title_galley.rect.height(); 278 title_font_max_size + desc_font_max_size + (2.0 * height_padding) 279 }; 280 281 let helper = AnimationHelper::new(ui, data.title, vec2(max_width, max_height)); 282 let animation_rect = helper.get_animation_rect(); 283 284 let cur_icon_width = helper.scale_1d_pos(min_icon_width); 285 let painter = ui.painter_at(animation_rect); 286 287 let cur_icon_size = vec2(cur_icon_width, cur_icon_width); 288 let cur_icon_x_pos = animation_rect.left() + (icon_padding) + (cur_icon_width / 2.0); 289 290 let title_cur_font = FontId::new( 291 helper.scale_1d_pos(title_min_font_size), 292 title_style.font_family(), 293 ); 294 295 let desc_cur_font = FontId::new( 296 helper.scale_1d_pos(desc_min_font_size), 297 desc_style.font_family(), 298 ); 299 300 let wrap_width = max_width - (cur_icon_width + (icon_padding * 2.0)); 301 let text_color = ui.ctx().style().visuals.text_color(); 302 let fallback_color = ui.ctx().style().visuals.weak_text_color(); 303 304 let title_galley = painter.layout( 305 data.title.to_string(), 306 title_cur_font, 307 text_color, 308 wrap_width, 309 ); 310 let desc_galley = painter.layout( 311 data.description.to_string(), 312 desc_cur_font, 313 text_color, 314 wrap_width, 315 ); 316 317 let galley_heights = title_galley.rect.height() + desc_galley.rect.height(); 318 319 let cur_height_padding = (animation_rect.height() - galley_heights) / 2.0; 320 let corner_x_pos = cur_icon_x_pos + (cur_icon_width / 2.0) + icon_padding; 321 let title_corner_pos = Pos2::new(corner_x_pos, animation_rect.top() + cur_height_padding); 322 let desc_corner_pos = Pos2::new( 323 corner_x_pos, 324 title_corner_pos.y + title_galley.rect.height(), 325 ); 326 327 let icon_cur_y = animation_rect.top() + cur_height_padding + (galley_heights / 2.0); 328 let icon_img = egui::Image::new(data.icon).fit_to_exact_size(cur_icon_size); 329 let icon_rect = Rect::from_center_size(pos2(cur_icon_x_pos, icon_cur_y), cur_icon_size); 330 331 icon_img.paint_at(ui, icon_rect); 332 painter.galley(title_corner_pos, title_galley, fallback_color); 333 painter.galley(desc_corner_pos, desc_galley, fallback_color); 334 335 helper.take_animation_response() 336 } 337 338 fn get_base_options(&self) -> Vec<ColumnOptionData> { 339 let mut vec = Vec::new(); 340 vec.push(ColumnOptionData { 341 title: "Universe", 342 description: "See the whole nostr universe", 343 icon: egui::include_image!("../../../../assets/icons/universe_icon_dark_4x.png"), 344 option: AddColumnOption::Universe, 345 }); 346 347 if let Some(acc) = self.cur_account { 348 let source = if acc.secret_key.is_some() { 349 PubkeySource::DeckAuthor 350 } else { 351 PubkeySource::Explicit(acc.pubkey) 352 }; 353 354 vec.push(ColumnOptionData { 355 title: "Home timeline", 356 description: "See recommended notes first", 357 icon: egui::include_image!("../../../../assets/icons/home_icon_dark_4x.png"), 358 option: AddColumnOption::Home(source.clone()), 359 }); 360 } 361 vec.push(ColumnOptionData { 362 title: "Notifications", 363 description: "Stay up to date with notifications and mentions", 364 icon: egui::include_image!("../../../../assets/icons/notifications_icon_dark_4x.png"), 365 option: AddColumnOption::UndecidedNotification, 366 }); 367 vec.push(ColumnOptionData { 368 title: "Hashtag", 369 description: "Stay up to date with a certain hashtag", 370 icon: egui::include_image!("../../../../assets/icons/notifications_icon_dark_4x.png"), 371 option: AddColumnOption::UndecidedHashtag, 372 }); 373 vec.push(ColumnOptionData { 374 title: "Individual", 375 description: "Stay up to date with someone's notes & replies", 376 icon: egui::include_image!("../../../../assets/icons/profile_icon_4x.png"), 377 option: AddColumnOption::UndecidedIndividual, 378 }); 379 380 vec 381 } 382 383 fn get_notifications_options(&self) -> Vec<ColumnOptionData> { 384 let mut vec = Vec::new(); 385 386 if let Some(acc) = self.cur_account { 387 let source = if acc.secret_key.is_some() { 388 PubkeySource::DeckAuthor 389 } else { 390 PubkeySource::Explicit(acc.pubkey) 391 }; 392 393 vec.push(ColumnOptionData { 394 title: "Your Notifications", 395 description: "Stay up to date with your notifications and mentions", 396 icon: egui::include_image!( 397 "../../../../assets/icons/notifications_icon_dark_4x.png" 398 ), 399 option: AddColumnOption::Notification(source), 400 }); 401 } 402 403 vec.push(ColumnOptionData { 404 title: "Someone else's Notifications", 405 description: "Stay up to date with someone else's notifications and mentions", 406 icon: egui::include_image!("../../../../assets/icons/notifications_icon_dark_4x.png"), 407 option: AddColumnOption::ExternalNotification, 408 }); 409 410 vec 411 } 412 413 fn get_individual_options(&self) -> Vec<ColumnOptionData> { 414 let mut vec = Vec::new(); 415 416 if let Some(acc) = self.cur_account { 417 let source = if acc.secret_key.is_some() { 418 PubkeySource::DeckAuthor 419 } else { 420 PubkeySource::Explicit(acc.pubkey) 421 }; 422 423 vec.push(ColumnOptionData { 424 title: "Your Notes", 425 description: "Keep track of your notes & replies", 426 icon: egui::include_image!("../../../../assets/icons/profile_icon_4x.png"), 427 option: AddColumnOption::Individual(source), 428 }); 429 } 430 431 vec.push(ColumnOptionData { 432 title: "Someone else's Notes", 433 description: "Stay up to date with someone else's notes & replies", 434 icon: egui::include_image!("../../../../assets/icons/profile_icon_4x.png"), 435 option: AddColumnOption::ExternalIndividual, 436 }); 437 438 vec 439 } 440 } 441 442 fn find_user_button() -> impl Widget { 443 sized_button("Find User") 444 } 445 446 fn add_column_button() -> impl Widget { 447 sized_button("Add") 448 } 449 450 fn sized_button(text: &str) -> impl Widget + '_ { 451 move |ui: &mut egui::Ui| -> egui::Response { 452 let painter = ui.painter(); 453 let galley = painter.layout( 454 text.to_owned(), 455 NotedeckTextStyle::Body.get_font_id(ui.ctx()), 456 Color32::WHITE, 457 ui.available_width(), 458 ); 459 460 ui.add_sized( 461 galley.rect.expand2(vec2(16.0, 8.0)).size(), 462 Button::new(galley).rounding(8.0).fill(crate::colors::PINK), 463 ) 464 } 465 } 466 467 struct ColumnOptionData { 468 title: &'static str, 469 description: &'static str, 470 icon: ImageSource<'static>, 471 option: AddColumnOption, 472 } 473 474 pub fn render_add_column_routes( 475 ui: &mut egui::Ui, 476 app: &mut Damus, 477 ctx: &mut AppContext<'_>, 478 col: usize, 479 route: &AddColumnRoute, 480 ) { 481 let mut add_column_view = AddColumnView::new( 482 &mut app.view_state.id_state_map, 483 ctx.ndb, 484 ctx.img_cache, 485 ctx.accounts.get_selected_account(), 486 ); 487 let resp = match route { 488 AddColumnRoute::Base => add_column_view.ui(ui), 489 AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), 490 AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui), 491 AddColumnRoute::Hashtag => hashtag_ui(ui, ctx.ndb, &mut app.view_state.id_string_map), 492 AddColumnRoute::UndecidedIndividual => add_column_view.individual_ui(ui), 493 AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui), 494 }; 495 496 if let Some(resp) = resp { 497 match resp { 498 AddColumnResponse::Timeline(mut timeline) => { 499 crate::timeline::setup_new_timeline( 500 &mut timeline, 501 ctx.ndb, 502 &mut app.subscriptions, 503 ctx.pool, 504 ctx.note_cache, 505 app.since_optimize, 506 &ctx.accounts.mutefun(), 507 ctx.accounts 508 .get_selected_account() 509 .as_ref() 510 .map(|sa| &sa.pubkey), 511 ); 512 app.columns_mut(ctx.accounts) 513 .add_timeline_to_column(col, timeline); 514 } 515 AddColumnResponse::UndecidedNotification => { 516 app.columns_mut(ctx.accounts) 517 .column_mut(col) 518 .router_mut() 519 .route_to(crate::route::Route::AddColumn( 520 AddColumnRoute::UndecidedNotification, 521 )); 522 } 523 AddColumnResponse::ExternalNotification => { 524 app.columns_mut(ctx.accounts) 525 .column_mut(col) 526 .router_mut() 527 .route_to(crate::route::Route::AddColumn( 528 AddColumnRoute::ExternalNotification, 529 )); 530 } 531 AddColumnResponse::Hashtag => { 532 app.columns_mut(ctx.accounts) 533 .column_mut(col) 534 .router_mut() 535 .route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag)); 536 } 537 AddColumnResponse::UndecidedIndividual => { 538 app.columns_mut(ctx.accounts) 539 .column_mut(col) 540 .router_mut() 541 .route_to(crate::route::Route::AddColumn( 542 AddColumnRoute::UndecidedIndividual, 543 )); 544 } 545 AddColumnResponse::ExternalIndividual => { 546 app.columns_mut(ctx.accounts) 547 .column_mut(col) 548 .router_mut() 549 .route_to(crate::route::Route::AddColumn( 550 AddColumnRoute::ExternalIndividual, 551 )); 552 } 553 }; 554 } 555 } 556 557 pub fn hashtag_ui( 558 ui: &mut Ui, 559 ndb: &Ndb, 560 id_string_map: &mut HashMap<Id, String>, 561 ) -> Option<AddColumnResponse> { 562 padding(16.0, ui, |ui| { 563 let id = ui.id().with("hashtag)"); 564 let text_buffer = id_string_map.entry(id).or_default(); 565 566 let text_edit = egui::TextEdit::singleline(text_buffer) 567 .hint_text( 568 RichText::new("Enter the desired hashtag here") 569 .text_style(NotedeckTextStyle::Body.text_style()), 570 ) 571 .vertical_align(Align::Center) 572 .desired_width(f32::INFINITY) 573 .min_size(Vec2::new(0.0, 40.0)) 574 .margin(Margin::same(12.0)); 575 ui.add(text_edit); 576 577 ui.add_space(8.0); 578 if ui 579 .add_sized(egui::vec2(50.0, 40.0), add_column_button()) 580 .clicked() 581 { 582 let resp = AddColumnOption::Hashtag(text_buffer.to_owned()).take_as_response(ndb, None); 583 id_string_map.remove(&id); 584 resp 585 } else { 586 None 587 } 588 }) 589 .inner 590 }