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