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