add_column.rs (33412B)
1 use core::f32; 2 use std::collections::HashMap; 3 4 use egui::{ 5 pos2, vec2, Align, Color32, FontId, Id, Image, Margin, Pos2, Rect, RichText, ScrollArea, 6 Separator, Ui, Vec2, Widget, 7 }; 8 use enostr::Pubkey; 9 use nostrdb::{Ndb, Transaction}; 10 use tracing::error; 11 12 use crate::{ 13 login_manager::AcquireKeyState, 14 options::AppOptions, 15 route::Route, 16 timeline::{kind::ListKind, PubkeySource, TimelineKind}, 17 Damus, 18 }; 19 20 use notedeck::{ 21 tr, AppContext, Images, Localization, MediaJobSender, NotedeckTextStyle, UserAccount, 22 }; 23 use notedeck_ui::{anim::ICON_EXPANSION_MULTIPLE, app_images}; 24 use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; 25 26 use crate::ui::widgets::styled_button; 27 use notedeck_ui::{anim::AnimationHelper, padding, ProfilePreview}; 28 29 pub enum AddColumnResponse { 30 Timeline(TimelineKind), 31 UndecidedNotification, 32 ExternalNotification, 33 Hashtag, 34 Algo(AlgoOption), 35 UndecidedIndividual, 36 ExternalIndividual, 37 } 38 39 pub enum NotificationColumnType { 40 Contacts, 41 External, 42 } 43 44 #[derive(Clone, Debug)] 45 pub enum Decision<T> { 46 Undecided, 47 Decided(T), 48 } 49 50 #[derive(Clone, Debug)] 51 pub enum AlgoOption { 52 LastPerPubkey(Decision<ListKind>), 53 } 54 55 #[derive(Clone, Debug)] 56 enum AddColumnOption { 57 Universe, 58 UndecidedNotification, 59 ExternalNotification, 60 Algo(AlgoOption), 61 Notification(PubkeySource), 62 Contacts(PubkeySource), 63 UndecidedHashtag, 64 UndecidedIndividual, 65 ExternalIndividual, 66 Individual(PubkeySource), 67 } 68 69 #[derive(Clone, Copy, Eq, PartialEq, Debug, Default, Hash)] 70 pub enum AddAlgoRoute { 71 #[default] 72 Base, 73 LastPerPubkey, 74 } 75 76 #[derive(Clone, Copy, Eq, PartialEq, Debug, Hash)] 77 pub enum AddColumnRoute { 78 Base, 79 UndecidedNotification, 80 ExternalNotification, 81 Hashtag, 82 Algo(AddAlgoRoute), 83 UndecidedIndividual, 84 ExternalIndividual, 85 } 86 87 // Parser for the common case without any payloads 88 fn parse_column_route<'a>( 89 parser: &mut TokenParser<'a>, 90 route: AddColumnRoute, 91 ) -> Result<AddColumnRoute, ParseError<'a>> { 92 parser.parse_all(|p| { 93 for token in route.tokens() { 94 p.parse_token(token)?; 95 } 96 Ok(route) 97 }) 98 } 99 100 impl AddColumnRoute { 101 /// Route tokens use in both serialization and deserialization 102 fn tokens(&self) -> &'static [&'static str] { 103 match self { 104 Self::Base => &["column"], 105 Self::UndecidedNotification => &["column", "notification_selection"], 106 Self::ExternalNotification => &["column", "external_notif_selection"], 107 Self::UndecidedIndividual => &["column", "individual_selection"], 108 Self::ExternalIndividual => &["column", "external_individual_selection"], 109 Self::Hashtag => &["column", "hashtag"], 110 Self::Algo(AddAlgoRoute::Base) => &["column", "algo_selection"], 111 Self::Algo(AddAlgoRoute::LastPerPubkey) => { 112 &["column", "algo_selection", "last_per_pubkey"] 113 } // NOTE!!! When adding to this, update the parser for TokenSerializable below 114 } 115 } 116 } 117 118 impl TokenSerializable for AddColumnRoute { 119 fn serialize_tokens(&self, writer: &mut TokenWriter) { 120 for token in self.tokens() { 121 writer.write_token(token); 122 } 123 } 124 125 fn parse_from_tokens<'a>(parser: &mut TokenParser<'a>) -> Result<Self, ParseError<'a>> { 126 parser.peek_parse_token("column")?; 127 128 TokenParser::alt( 129 parser, 130 &[ 131 |p| parse_column_route(p, AddColumnRoute::Base), 132 |p| parse_column_route(p, AddColumnRoute::UndecidedNotification), 133 |p| parse_column_route(p, AddColumnRoute::ExternalNotification), 134 |p| parse_column_route(p, AddColumnRoute::UndecidedIndividual), 135 |p| parse_column_route(p, AddColumnRoute::ExternalIndividual), 136 |p| parse_column_route(p, AddColumnRoute::Hashtag), 137 |p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::Base)), 138 |p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey)), 139 ], 140 ) 141 } 142 } 143 144 impl AddColumnOption { 145 pub fn take_as_response(self, cur_account: &UserAccount) -> AddColumnResponse { 146 match self { 147 AddColumnOption::Algo(algo_option) => AddColumnResponse::Algo(algo_option), 148 AddColumnOption::Universe => AddColumnResponse::Timeline(TimelineKind::Universe), 149 AddColumnOption::Notification(pubkey) => AddColumnResponse::Timeline( 150 TimelineKind::Notifications(*pubkey.as_pubkey(&cur_account.key.pubkey)), 151 ), 152 AddColumnOption::UndecidedNotification => AddColumnResponse::UndecidedNotification, 153 AddColumnOption::Contacts(pk_src) => AddColumnResponse::Timeline( 154 TimelineKind::contact_list(*pk_src.as_pubkey(&cur_account.key.pubkey)), 155 ), 156 AddColumnOption::ExternalNotification => AddColumnResponse::ExternalNotification, 157 AddColumnOption::UndecidedHashtag => AddColumnResponse::Hashtag, 158 AddColumnOption::UndecidedIndividual => AddColumnResponse::UndecidedIndividual, 159 AddColumnOption::ExternalIndividual => AddColumnResponse::ExternalIndividual, 160 AddColumnOption::Individual(pubkey_source) => AddColumnResponse::Timeline( 161 TimelineKind::profile(*pubkey_source.as_pubkey(&cur_account.key.pubkey)), 162 ), 163 } 164 } 165 } 166 167 pub struct AddColumnView<'a> { 168 key_state_map: &'a mut HashMap<Id, AcquireKeyState>, 169 ndb: &'a Ndb, 170 img_cache: &'a mut Images, 171 cur_account: &'a UserAccount, 172 i18n: &'a mut Localization, 173 jobs: &'a MediaJobSender, 174 } 175 176 impl<'a> AddColumnView<'a> { 177 pub fn new( 178 key_state_map: &'a mut HashMap<Id, AcquireKeyState>, 179 ndb: &'a Ndb, 180 img_cache: &'a mut Images, 181 cur_account: &'a UserAccount, 182 i18n: &'a mut Localization, 183 jobs: &'a MediaJobSender, 184 ) -> Self { 185 Self { 186 key_state_map, 187 ndb, 188 img_cache, 189 cur_account, 190 i18n, 191 jobs, 192 } 193 } 194 195 pub fn scroll_id(route: &AddColumnRoute) -> egui::Id { 196 egui::Id::new(("add_column", route)) 197 } 198 199 pub fn ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 200 ScrollArea::vertical() 201 .id_salt(AddColumnView::scroll_id(&AddColumnRoute::Base)) 202 .show(ui, |ui| { 203 let mut selected_option: Option<AddColumnResponse> = None; 204 for column_option_data in self.get_base_options(ui) { 205 let option = column_option_data.option.clone(); 206 if self.column_option_ui(ui, column_option_data).clicked() { 207 selected_option = Some(option.take_as_response(self.cur_account)); 208 } 209 210 ui.add(Separator::default().spacing(0.0)); 211 } 212 213 selected_option 214 }) 215 .inner 216 } 217 218 fn notifications_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 219 let mut selected_option: Option<AddColumnResponse> = None; 220 for column_option_data in self.get_notifications_options(ui) { 221 let option = column_option_data.option.clone(); 222 if self.column_option_ui(ui, column_option_data).clicked() { 223 selected_option = Some(option.take_as_response(self.cur_account)); 224 } 225 226 ui.add(Separator::default().spacing(0.0)); 227 } 228 229 selected_option 230 } 231 232 fn external_notification_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 233 let id = ui.id().with("external_notif"); 234 self.external_ui(ui, id, |pubkey| { 235 AddColumnOption::Notification(PubkeySource::Explicit(pubkey)) 236 }) 237 } 238 239 fn algo_last_per_pk_ui( 240 &mut self, 241 ui: &mut Ui, 242 deck_author: Pubkey, 243 ) -> Option<AddColumnResponse> { 244 let algo_option = ColumnOptionData { 245 title: tr!(self.i18n, "Contact List", "Title for contact list column"), 246 description: tr!( 247 self.i18n, 248 "Source the last note for each user in your contact list", 249 "Description for contact list column" 250 ), 251 icon: app_images::home_image(), 252 option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Decided( 253 ListKind::contact_list(deck_author), 254 ))), 255 }; 256 257 let option = algo_option.option.clone(); 258 self.column_option_ui(ui, algo_option) 259 .clicked() 260 .then(|| option.take_as_response(self.cur_account)) 261 } 262 263 fn algo_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 264 let algo_option = ColumnOptionData { 265 title: tr!( 266 self.i18n, 267 "Last Note per User", 268 "Title for last note per user column" 269 ), 270 description: tr!( 271 self.i18n, 272 "Show the last note for each user from a list", 273 "Description for last note per user column" 274 ), 275 icon: app_images::algo_image(), 276 option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)), 277 }; 278 279 let option = algo_option.option.clone(); 280 self.column_option_ui(ui, algo_option) 281 .clicked() 282 .then(|| option.take_as_response(self.cur_account)) 283 } 284 285 fn individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 286 let mut selected_option: Option<AddColumnResponse> = None; 287 for column_option_data in self.get_individual_options() { 288 let option = column_option_data.option.clone(); 289 if self.column_option_ui(ui, column_option_data).clicked() { 290 selected_option = Some(option.take_as_response(self.cur_account)); 291 } 292 293 ui.add(Separator::default().spacing(0.0)); 294 } 295 296 selected_option 297 } 298 299 fn external_individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 300 let id = ui.id().with("external_individual"); 301 302 self.external_ui(ui, id, |pubkey| { 303 AddColumnOption::Individual(PubkeySource::Explicit(pubkey)) 304 }) 305 } 306 307 fn external_ui( 308 &mut self, 309 ui: &mut Ui, 310 id: egui::Id, 311 to_option: fn(Pubkey) -> AddColumnOption, 312 ) -> Option<AddColumnResponse> { 313 padding(16.0, ui, |ui| { 314 let key_state = self.key_state_map.entry(id).or_default(); 315 316 let text_edit = key_state.get_acquire_textedit(|text| { 317 egui::TextEdit::singleline(text) 318 .hint_text( 319 RichText::new(tr!( 320 self.i18n, 321 "Enter the user's key (npub, hex, nip05) here...", 322 "Hint text to prompt entering the user's public key." 323 )) 324 .text_style(NotedeckTextStyle::Body.text_style()), 325 ) 326 .vertical_align(Align::Center) 327 .desired_width(f32::INFINITY) 328 .min_size(Vec2::new(0.0, 40.0)) 329 .margin(Margin::same(12)) 330 }); 331 332 ui.add(text_edit); 333 334 key_state.handle_input_change_after_acquire(); 335 key_state.loading_and_error_ui(ui, self.i18n); 336 337 if key_state.get_login_keypair().is_none() 338 && ui.add(find_user_button(self.i18n)).clicked() 339 { 340 key_state.apply_acquire(); 341 } 342 343 let resp = if let Some(keypair) = key_state.get_login_keypair() { 344 { 345 let txn = Transaction::new(self.ndb).expect("txn"); 346 if let Ok(profile) = 347 self.ndb.get_profile_by_pubkey(&txn, keypair.pubkey.bytes()) 348 { 349 egui::Frame::window(ui.style()) 350 .outer_margin(Margin { 351 left: 4, 352 right: 4, 353 top: 12, 354 bottom: 32, 355 }) 356 .show(ui, |ui| { 357 ProfilePreview::new(&profile, self.img_cache, self.jobs).ui(ui); 358 }); 359 } 360 } 361 362 ui.add(add_column_button(self.i18n)) 363 .clicked() 364 .then(|| to_option(keypair.pubkey).take_as_response(self.cur_account)) 365 } else { 366 None 367 }; 368 if resp.is_some() { 369 self.key_state_map.remove(&id); 370 }; 371 resp 372 }) 373 .inner 374 } 375 376 fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response { 377 let icon_padding = 8.0; 378 let min_icon_width = 32.0; 379 let height_padding = 12.0; 380 let inter_text_padding = 4.0; // Padding between title and description 381 let max_width = ui.available_width(); 382 let title_style = NotedeckTextStyle::Body; 383 let desc_style = NotedeckTextStyle::Button; 384 let title_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &title_style); 385 let desc_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &desc_style); 386 387 let max_height = { 388 let max_wrap_width = 389 max_width - ((icon_padding * 2.0) + (min_icon_width * ICON_EXPANSION_MULTIPLE)); 390 let title_max_font = FontId::new( 391 title_min_font_size * ICON_EXPANSION_MULTIPLE, 392 title_style.font_family(), 393 ); 394 let desc_max_font = FontId::new( 395 desc_min_font_size * ICON_EXPANSION_MULTIPLE, 396 desc_style.font_family(), 397 ); 398 let max_desc_galley = ui.fonts(|f| { 399 f.layout( 400 data.description.to_string(), 401 desc_max_font, 402 ui.style().visuals.noninteractive().fg_stroke.color, 403 max_wrap_width, 404 ) 405 }); 406 let max_title_galley = ui.fonts(|f| { 407 f.layout( 408 data.title.to_string(), 409 title_max_font, 410 Color32::WHITE, 411 max_wrap_width, 412 ) 413 }); 414 415 let desc_font_max_size = max_desc_galley.rect.height(); 416 let title_font_max_size = max_title_galley.rect.height(); 417 title_font_max_size + inter_text_padding + desc_font_max_size + (2.0 * height_padding) 418 }; 419 420 let title = data.title.clone(); 421 let helper = AnimationHelper::new(ui, title.clone(), vec2(max_width, max_height)); 422 let animation_rect = helper.get_animation_rect(); 423 424 let cur_icon_width = helper.scale_1d_pos(min_icon_width); 425 let painter = ui.painter_at(animation_rect); 426 427 let cur_icon_size = vec2(cur_icon_width, cur_icon_width); 428 let cur_icon_x_pos = animation_rect.left() + icon_padding + (cur_icon_width / 2.0); 429 430 let title_cur_font = FontId::new( 431 helper.scale_1d_pos(title_min_font_size), 432 title_style.font_family(), 433 ); 434 let desc_cur_font = FontId::new( 435 helper.scale_1d_pos(desc_min_font_size), 436 desc_style.font_family(), 437 ); 438 439 let wrap_width = max_width - (cur_icon_width + (icon_padding * 2.0)); 440 let text_color = ui.style().visuals.text_color(); 441 let fallback_color = ui.style().visuals.noninteractive().fg_stroke.color; 442 443 let title_galley = painter.layout( 444 data.title.to_string(), 445 title_cur_font, 446 text_color, 447 wrap_width, 448 ); 449 let desc_galley = painter.layout( 450 data.description.to_string(), 451 desc_cur_font, 452 fallback_color, 453 wrap_width, 454 ); 455 456 let total_content_height = 457 title_galley.rect.height() + inter_text_padding + desc_galley.rect.height(); 458 let cur_height_padding = (animation_rect.height() - total_content_height) / 2.0; 459 let corner_x_pos = cur_icon_x_pos + (cur_icon_width / 2.0) + icon_padding; 460 let title_corner_pos = Pos2::new(corner_x_pos, animation_rect.top() + cur_height_padding); 461 let desc_corner_pos = Pos2::new( 462 corner_x_pos, 463 title_corner_pos.y + title_galley.rect.height() + inter_text_padding, 464 ); 465 466 let icon_cur_y = animation_rect.top() + cur_height_padding + (total_content_height / 2.0); 467 let icon_img = data.icon.fit_to_exact_size(cur_icon_size); 468 let icon_rect = Rect::from_center_size(pos2(cur_icon_x_pos, icon_cur_y), cur_icon_size); 469 470 icon_img.paint_at(ui, icon_rect); 471 painter.galley(title_corner_pos, title_galley, text_color); 472 painter.galley(desc_corner_pos, desc_galley, fallback_color); 473 474 helper.take_animation_response() 475 } 476 477 fn get_base_options(&mut self, ui: &mut Ui) -> Vec<ColumnOptionData> { 478 let mut vec = Vec::new(); 479 vec.push(ColumnOptionData { 480 title: tr!(self.i18n, "Home", "Title for Home column"), 481 description: tr!( 482 self.i18n, 483 "See notes from your contacts", 484 "Description for Home column" 485 ), 486 icon: app_images::home_image(), 487 option: AddColumnOption::Contacts(if self.cur_account.key.secret_key.is_some() { 488 PubkeySource::DeckAuthor 489 } else { 490 PubkeySource::Explicit(self.cur_account.key.pubkey) 491 }), 492 }); 493 vec.push(ColumnOptionData { 494 title: tr!(self.i18n, "Notifications", "Title for notifications column"), 495 description: tr!( 496 self.i18n, 497 "Stay up to date with notifications and mentions", 498 "Description for notifications column" 499 ), 500 icon: app_images::notifications_image(ui.visuals().dark_mode), 501 option: AddColumnOption::UndecidedNotification, 502 }); 503 vec.push(ColumnOptionData { 504 title: tr!(self.i18n, "Universe", "Title for universe column"), 505 description: tr!( 506 self.i18n, 507 "See the whole nostr universe", 508 "Description for universe column" 509 ), 510 icon: app_images::universe_image(), 511 option: AddColumnOption::Universe, 512 }); 513 vec.push(ColumnOptionData { 514 title: tr!(self.i18n, "Hashtags", "Title for hashtags column"), 515 description: tr!( 516 self.i18n, 517 "Stay up to date with a certain hashtag", 518 "Description for hashtags column" 519 ), 520 icon: app_images::hashtag_image(), 521 option: AddColumnOption::UndecidedHashtag, 522 }); 523 vec.push(ColumnOptionData { 524 title: tr!(self.i18n, "Individual", "Title for individual user column"), 525 description: tr!( 526 self.i18n, 527 "Stay up to date with someone's notes & replies", 528 "Description for individual user column" 529 ), 530 icon: app_images::add_column_individual_image(), 531 option: AddColumnOption::UndecidedIndividual, 532 }); 533 vec.push(ColumnOptionData { 534 title: tr!(self.i18n, "Algo", "Title for algorithmic feeds column"), 535 description: tr!( 536 self.i18n, 537 "Algorithmic feeds to aid in note discovery", 538 "Description for algorithmic feeds column" 539 ), 540 icon: app_images::algo_image(), 541 option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)), 542 }); 543 544 vec 545 } 546 547 fn get_notifications_options(&mut self, ui: &mut Ui) -> Vec<ColumnOptionData> { 548 let mut vec = Vec::new(); 549 550 let source = if self.cur_account.key.secret_key.is_some() { 551 PubkeySource::DeckAuthor 552 } else { 553 PubkeySource::Explicit(self.cur_account.key.pubkey) 554 }; 555 556 vec.push(ColumnOptionData { 557 title: tr!( 558 self.i18n, 559 "Your Notifications", 560 "Title for your notifications column" 561 ), 562 description: tr!( 563 self.i18n, 564 "Stay up to date with your notifications and mentions", 565 "Description for your notifications column" 566 ), 567 icon: app_images::notifications_image(ui.visuals().dark_mode), 568 option: AddColumnOption::Notification(source), 569 }); 570 571 vec.push(ColumnOptionData { 572 title: tr!( 573 self.i18n, 574 "Someone else's Notifications", 575 "Title for someone else's notifications column" 576 ), 577 description: tr!( 578 self.i18n, 579 "Stay up to date with someone else's notifications and mentions", 580 "Description for someone else's notifications column" 581 ), 582 icon: app_images::notifications_image(ui.visuals().dark_mode), 583 option: AddColumnOption::ExternalNotification, 584 }); 585 586 vec 587 } 588 589 fn get_individual_options(&mut self) -> Vec<ColumnOptionData> { 590 let mut vec = Vec::new(); 591 592 let source = if self.cur_account.key.secret_key.is_some() { 593 PubkeySource::DeckAuthor 594 } else { 595 PubkeySource::Explicit(self.cur_account.key.pubkey) 596 }; 597 598 vec.push(ColumnOptionData { 599 title: tr!(self.i18n, "Your Notes", "Title for your notes column"), 600 description: tr!( 601 self.i18n, 602 "Keep track of your notes & replies", 603 "Description for your notes column" 604 ), 605 icon: app_images::add_column_individual_image(), 606 option: AddColumnOption::Individual(source), 607 }); 608 609 vec.push(ColumnOptionData { 610 title: tr!( 611 self.i18n, 612 "Someone else's Notes", 613 "Title for someone else's notes column" 614 ), 615 description: tr!( 616 self.i18n, 617 "Stay up to date with someone else's notes & replies", 618 "Description for someone else's notes column" 619 ), 620 icon: app_images::add_column_individual_image(), 621 option: AddColumnOption::ExternalIndividual, 622 }); 623 624 vec 625 } 626 } 627 628 fn find_user_button(i18n: &mut Localization) -> impl Widget { 629 let label = tr!(i18n, "Find User", "Label for find user button"); 630 let color = notedeck_ui::colors::PINK; 631 move |ui: &mut egui::Ui| styled_button(label.as_str(), color).ui(ui) 632 } 633 634 fn add_column_button(i18n: &mut Localization) -> impl Widget { 635 let label = tr!(i18n, "Add", "Label for add column button"); 636 let color = notedeck_ui::colors::PINK; 637 move |ui: &mut egui::Ui| styled_button(label.as_str(), color).ui(ui) 638 } 639 640 /* 641 pub(crate) fn sized_button(text: &str) -> impl Widget + '_ { 642 move |ui: &mut egui::Ui| -> egui::Response { 643 let painter = ui.painter(); 644 let galley = painter.layout( 645 text.to_owned(), 646 NotedeckTextStyle::Body.get_font_id(ui.ctx()), 647 Color32::WHITE, 648 ui.available_width(), 649 ); 650 651 ui.add_sized( 652 galley.rect.expand2(vec2(16.0, 8.0)).size(), 653 egui::Button::new(galley) 654 .corner_radius(8.0) 655 .fill(notedeck_ui::colors::PINK), 656 ) 657 } 658 } 659 */ 660 661 struct ColumnOptionData { 662 title: String, 663 description: String, 664 icon: Image<'static>, 665 option: AddColumnOption, 666 } 667 668 pub fn render_add_column_routes( 669 ui: &mut egui::Ui, 670 app: &mut Damus, 671 ctx: &mut AppContext<'_>, 672 col: usize, 673 route: &AddColumnRoute, 674 ) { 675 let mut add_column_view = AddColumnView::new( 676 &mut app.view_state.id_state_map, 677 ctx.ndb, 678 ctx.img_cache, 679 ctx.accounts.get_selected_account(), 680 ctx.i18n, 681 ctx.media_jobs.sender(), 682 ); 683 let resp = match route { 684 AddColumnRoute::Base => add_column_view.ui(ui), 685 AddColumnRoute::Algo(r) => match r { 686 AddAlgoRoute::Base => add_column_view.algo_ui(ui), 687 AddAlgoRoute::LastPerPubkey => add_column_view 688 .algo_last_per_pk_ui(ui, ctx.accounts.get_selected_account().key.pubkey), 689 }, 690 AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), 691 AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui), 692 AddColumnRoute::Hashtag => hashtag_ui(ui, ctx.i18n, &mut app.view_state.id_string_map), 693 AddColumnRoute::UndecidedIndividual => add_column_view.individual_ui(ui), 694 AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui), 695 }; 696 697 if let Some(resp) = resp { 698 match resp { 699 AddColumnResponse::Timeline(timeline_kind) => 'leave: { 700 let txn = Transaction::new(ctx.ndb).unwrap(); 701 let mut timeline = 702 if let Some(timeline) = timeline_kind.into_timeline(&txn, ctx.ndb) { 703 timeline 704 } else { 705 error!("Could not convert column response to timeline"); 706 break 'leave; 707 }; 708 709 crate::timeline::setup_new_timeline( 710 &mut timeline, 711 ctx.ndb, 712 &txn, 713 &mut app.subscriptions, 714 ctx.pool, 715 ctx.note_cache, 716 app.options.contains(AppOptions::SinceOptimize), 717 ctx.accounts, 718 ctx.unknown_ids, 719 ); 720 721 app.columns_mut(ctx.i18n, ctx.accounts) 722 .column_mut(col) 723 .router_mut() 724 .route_to_replaced(Route::timeline(timeline.kind.clone())); 725 726 app.timeline_cache.insert(timeline.kind.clone(), timeline); 727 } 728 729 AddColumnResponse::Algo(algo_option) => match algo_option { 730 // If we are undecided, we simply route to the LastPerPubkey 731 // algo route selection 732 AlgoOption::LastPerPubkey(Decision::Undecided) => { 733 app.columns_mut(ctx.i18n, ctx.accounts) 734 .column_mut(col) 735 .router_mut() 736 .route_to(Route::AddColumn(AddColumnRoute::Algo( 737 AddAlgoRoute::LastPerPubkey, 738 ))); 739 } 740 741 // We have a decision on where we want the last per pubkey 742 // source to be, so let's create a timeline from that and 743 // add it to our list of timelines 744 AlgoOption::LastPerPubkey(Decision::Decided(list_kind)) => { 745 let txn = Transaction::new(ctx.ndb).unwrap(); 746 let maybe_timeline = 747 TimelineKind::last_per_pubkey(list_kind).into_timeline(&txn, ctx.ndb); 748 749 if let Some(mut timeline) = maybe_timeline { 750 crate::timeline::setup_new_timeline( 751 &mut timeline, 752 ctx.ndb, 753 &txn, 754 &mut app.subscriptions, 755 ctx.pool, 756 ctx.note_cache, 757 app.options.contains(AppOptions::SinceOptimize), 758 ctx.accounts, 759 ctx.unknown_ids, 760 ); 761 762 app.columns_mut(ctx.i18n, ctx.accounts) 763 .column_mut(col) 764 .router_mut() 765 .route_to_replaced(Route::timeline(timeline.kind.clone())); 766 767 app.timeline_cache.insert(timeline.kind.clone(), timeline); 768 } else { 769 // we couldn't fetch the timeline yet... let's let 770 // the user know ? 771 772 // TODO: spin off the list search here instead 773 774 ui.label(format!("error: could not find {list_kind:?}")); 775 } 776 } 777 }, 778 779 AddColumnResponse::UndecidedNotification => { 780 app.columns_mut(ctx.i18n, ctx.accounts) 781 .column_mut(col) 782 .router_mut() 783 .route_to(Route::AddColumn(AddColumnRoute::UndecidedNotification)); 784 } 785 AddColumnResponse::ExternalNotification => { 786 app.columns_mut(ctx.i18n, ctx.accounts) 787 .column_mut(col) 788 .router_mut() 789 .route_to(crate::route::Route::AddColumn( 790 AddColumnRoute::ExternalNotification, 791 )); 792 } 793 AddColumnResponse::Hashtag => { 794 app.columns_mut(ctx.i18n, ctx.accounts) 795 .column_mut(col) 796 .router_mut() 797 .route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag)); 798 } 799 AddColumnResponse::UndecidedIndividual => { 800 app.columns_mut(ctx.i18n, ctx.accounts) 801 .column_mut(col) 802 .router_mut() 803 .route_to(crate::route::Route::AddColumn( 804 AddColumnRoute::UndecidedIndividual, 805 )); 806 } 807 AddColumnResponse::ExternalIndividual => { 808 app.columns_mut(ctx.i18n, ctx.accounts) 809 .column_mut(col) 810 .router_mut() 811 .route_to(crate::route::Route::AddColumn( 812 AddColumnRoute::ExternalIndividual, 813 )); 814 } 815 }; 816 } 817 } 818 819 pub fn hashtag_ui( 820 ui: &mut Ui, 821 i18n: &mut Localization, 822 id_string_map: &mut HashMap<Id, String>, 823 ) -> Option<AddColumnResponse> { 824 padding(16.0, ui, |ui| { 825 let id = ui.id().with("hashtag)"); 826 let text_buffer = id_string_map.entry(id).or_default(); 827 828 let text_edit = egui::TextEdit::singleline(text_buffer) 829 .hint_text( 830 RichText::new(tr!( 831 i18n, 832 "Enter the desired hashtags here (for multiple space-separated)", 833 "Placeholder for hashtag input field" 834 )) 835 .text_style(NotedeckTextStyle::Body.text_style()), 836 ) 837 .vertical_align(Align::Center) 838 .desired_width(f32::INFINITY) 839 .min_size(Vec2::new(0.0, 40.0)) 840 .margin(Margin::same(12)); 841 ui.add(text_edit); 842 843 ui.add_space(8.0); 844 845 let mut handle_user_input = false; 846 if ui.input(|i| i.key_released(egui::Key::Enter)) 847 || ui 848 .add_sized(egui::vec2(50.0, 40.0), add_column_button(i18n)) 849 .clicked() 850 { 851 handle_user_input = true; 852 } 853 854 if handle_user_input && !text_buffer.is_empty() { 855 let resp = AddColumnResponse::Timeline(TimelineKind::Hashtag( 856 text_buffer 857 .split_whitespace() 858 .filter(|s| !s.is_empty()) 859 .map(|s| sanitize_hashtag(s).to_lowercase().to_string()) 860 .collect::<Vec<_>>(), 861 )); 862 id_string_map.remove(&id); 863 Some(resp) 864 } else { 865 None 866 } 867 }) 868 .inner 869 } 870 871 fn sanitize_hashtag(raw_hashtag: &str) -> String { 872 raw_hashtag 873 .chars() 874 .filter(|c| c.is_alphanumeric()) // keep letters and numbers only 875 .collect() 876 } 877 878 #[cfg(test)] 879 mod tests { 880 use super::*; 881 882 #[test] 883 fn test_column_serialize() { 884 use super::{AddAlgoRoute, AddColumnRoute}; 885 886 { 887 let data_str = "column:algo_selection:last_per_pubkey"; 888 let data = &data_str.split(":").collect::<Vec<&str>>(); 889 let mut token_writer = TokenWriter::default(); 890 let mut parser = TokenParser::new(data); 891 let parsed = AddColumnRoute::parse_from_tokens(&mut parser).unwrap(); 892 let expected = AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey); 893 parsed.serialize_tokens(&mut token_writer); 894 assert_eq!(expected, parsed); 895 assert_eq!(token_writer.str(), data_str); 896 } 897 898 { 899 let data_str = "column"; 900 let mut token_writer = TokenWriter::default(); 901 let data: &[&str] = &[data_str]; 902 let mut parser = TokenParser::new(data); 903 let parsed = AddColumnRoute::parse_from_tokens(&mut parser).unwrap(); 904 let expected = AddColumnRoute::Base; 905 parsed.serialize_tokens(&mut token_writer); 906 assert_eq!(expected, parsed); 907 assert_eq!(token_writer.str(), data_str); 908 } 909 } 910 }