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