add_column.rs (49801B)
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::{Filter, 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 use notedeck::{ 20 tr, AppContext, ContactState, Images, Localization, MediaJobSender, NotedeckTextStyle, 21 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::{ 28 anim::AnimationHelper, padding, profile_row, search_input_box, search_profiles, 29 ContactsListView, 30 }; 31 32 pub enum AddColumnResponse { 33 Timeline(TimelineKind), 34 UndecidedNotification, 35 ExternalNotification, 36 Hashtag, 37 Algo(AlgoOption), 38 UndecidedIndividual, 39 ExternalIndividual, 40 PeopleList, 41 CreatePeopleList, 42 FinishCreatePeopleList, 43 } 44 45 struct SelectionHandler<'a> { 46 cur_account: &'a UserAccount, 47 to_response: fn(Pubkey, &UserAccount) -> AddColumnResponse, 48 } 49 50 impl<'a> SelectionHandler<'a> { 51 fn response(&self, pubkey: Pubkey) -> AddColumnResponse { 52 (self.to_response)(pubkey, self.cur_account) 53 } 54 } 55 56 pub enum NotificationColumnType { 57 Contacts, 58 External, 59 } 60 61 #[derive(Clone, Debug)] 62 pub enum Decision<T> { 63 Undecided, 64 Decided(T), 65 } 66 67 #[derive(Clone, Debug)] 68 pub enum AlgoOption { 69 LastPerPubkey(Decision<ListKind>), 70 } 71 72 #[derive(Clone, Debug)] 73 enum AddColumnOption { 74 Universe, 75 UndecidedNotification, 76 ExternalNotification, 77 Algo(AlgoOption), 78 Notification(PubkeySource), 79 Contacts(PubkeySource), 80 UndecidedHashtag, 81 UndecidedIndividual, 82 ExternalIndividual, 83 Individual(PubkeySource), 84 UndecidedPeopleList, 85 } 86 87 #[derive(Clone, Copy, Eq, PartialEq, Debug, Default, Hash)] 88 pub enum AddAlgoRoute { 89 #[default] 90 Base, 91 LastPerPubkey, 92 } 93 94 #[derive(Clone, Copy, Eq, PartialEq, Debug, Hash)] 95 pub enum AddColumnRoute { 96 Base, 97 UndecidedNotification, 98 ExternalNotification, 99 Hashtag, 100 Algo(AddAlgoRoute), 101 UndecidedIndividual, 102 ExternalIndividual, 103 PeopleList, 104 CreatePeopleList, 105 } 106 107 // Parser for the common case without any payloads 108 fn parse_column_route<'a>( 109 parser: &mut TokenParser<'a>, 110 route: AddColumnRoute, 111 ) -> Result<AddColumnRoute, ParseError<'a>> { 112 parser.parse_all(|p| { 113 for token in route.tokens() { 114 p.parse_token(token)?; 115 } 116 Ok(route) 117 }) 118 } 119 120 impl AddColumnRoute { 121 /// Route tokens use in both serialization and deserialization 122 fn tokens(&self) -> &'static [&'static str] { 123 match self { 124 Self::Base => &["column"], 125 Self::UndecidedNotification => &["column", "notification_selection"], 126 Self::ExternalNotification => &["column", "external_notif_selection"], 127 Self::UndecidedIndividual => &["column", "individual_selection"], 128 Self::ExternalIndividual => &["column", "external_individual_selection"], 129 Self::Hashtag => &["column", "hashtag"], 130 Self::Algo(AddAlgoRoute::Base) => &["column", "algo_selection"], 131 Self::Algo(AddAlgoRoute::LastPerPubkey) => { 132 &["column", "algo_selection", "last_per_pubkey"] 133 } 134 Self::PeopleList => &["column", "people_list"], 135 Self::CreatePeopleList => &["column", "create_people_list"], 136 // NOTE!!! When adding to this, update the parser for TokenSerializable below 137 } 138 } 139 } 140 141 impl TokenSerializable for AddColumnRoute { 142 fn serialize_tokens(&self, writer: &mut TokenWriter) { 143 for token in self.tokens() { 144 writer.write_token(token); 145 } 146 } 147 148 fn parse_from_tokens<'a>(parser: &mut TokenParser<'a>) -> Result<Self, ParseError<'a>> { 149 parser.peek_parse_token("column")?; 150 151 TokenParser::alt( 152 parser, 153 &[ 154 |p| parse_column_route(p, AddColumnRoute::Base), 155 |p| parse_column_route(p, AddColumnRoute::UndecidedNotification), 156 |p| parse_column_route(p, AddColumnRoute::ExternalNotification), 157 |p| parse_column_route(p, AddColumnRoute::UndecidedIndividual), 158 |p| parse_column_route(p, AddColumnRoute::ExternalIndividual), 159 |p| parse_column_route(p, AddColumnRoute::Hashtag), 160 |p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::Base)), 161 |p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey)), 162 |p| parse_column_route(p, AddColumnRoute::PeopleList), 163 |p| parse_column_route(p, AddColumnRoute::CreatePeopleList), 164 ], 165 ) 166 } 167 } 168 169 impl AddColumnOption { 170 pub fn take_as_response(self, cur_account: &UserAccount) -> AddColumnResponse { 171 match self { 172 AddColumnOption::Algo(algo_option) => AddColumnResponse::Algo(algo_option), 173 AddColumnOption::Universe => AddColumnResponse::Timeline(TimelineKind::Universe), 174 AddColumnOption::Notification(pubkey) => AddColumnResponse::Timeline( 175 TimelineKind::Notifications(*pubkey.as_pubkey(&cur_account.key.pubkey)), 176 ), 177 AddColumnOption::UndecidedNotification => AddColumnResponse::UndecidedNotification, 178 AddColumnOption::Contacts(pk_src) => AddColumnResponse::Timeline( 179 TimelineKind::contact_list(*pk_src.as_pubkey(&cur_account.key.pubkey)), 180 ), 181 AddColumnOption::ExternalNotification => AddColumnResponse::ExternalNotification, 182 AddColumnOption::UndecidedHashtag => AddColumnResponse::Hashtag, 183 AddColumnOption::UndecidedIndividual => AddColumnResponse::UndecidedIndividual, 184 AddColumnOption::ExternalIndividual => AddColumnResponse::ExternalIndividual, 185 AddColumnOption::Individual(pubkey_source) => AddColumnResponse::Timeline( 186 TimelineKind::profile(*pubkey_source.as_pubkey(&cur_account.key.pubkey)), 187 ), 188 AddColumnOption::UndecidedPeopleList => AddColumnResponse::PeopleList, 189 } 190 } 191 } 192 193 pub struct AddColumnView<'a> { 194 key_state_map: &'a mut HashMap<Id, AcquireKeyState>, 195 id_string_map: &'a mut HashMap<Id, String>, 196 ndb: &'a Ndb, 197 img_cache: &'a mut Images, 198 cur_account: &'a UserAccount, 199 contacts: &'a ContactState, 200 i18n: &'a mut Localization, 201 jobs: &'a MediaJobSender, 202 unknown_ids: &'a mut notedeck::UnknownIds, 203 people_lists: &'a mut Option<notedeck::Nip51SetCache>, 204 } 205 206 impl<'a> AddColumnView<'a> { 207 #[allow(clippy::too_many_arguments)] 208 pub fn new( 209 key_state_map: &'a mut HashMap<Id, AcquireKeyState>, 210 id_string_map: &'a mut HashMap<Id, String>, 211 ndb: &'a Ndb, 212 img_cache: &'a mut Images, 213 cur_account: &'a UserAccount, 214 contacts: &'a ContactState, 215 i18n: &'a mut Localization, 216 jobs: &'a MediaJobSender, 217 unknown_ids: &'a mut notedeck::UnknownIds, 218 people_lists: &'a mut Option<notedeck::Nip51SetCache>, 219 ) -> Self { 220 Self { 221 key_state_map, 222 id_string_map, 223 ndb, 224 img_cache, 225 cur_account, 226 contacts, 227 i18n, 228 jobs, 229 unknown_ids, 230 people_lists, 231 } 232 } 233 234 pub fn scroll_id(route: &AddColumnRoute) -> egui::Id { 235 egui::Id::new(("add_column", route)) 236 } 237 238 pub fn ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 239 ScrollArea::vertical() 240 .id_salt(AddColumnView::scroll_id(&AddColumnRoute::Base)) 241 .show(ui, |ui| { 242 let mut selected_option: Option<AddColumnResponse> = None; 243 for column_option_data in self.get_base_options(ui) { 244 let option = column_option_data.option.clone(); 245 if self.column_option_ui(ui, column_option_data).clicked() { 246 selected_option = Some(option.take_as_response(self.cur_account)); 247 } 248 249 ui.add(Separator::default().spacing(0.0)); 250 } 251 252 selected_option 253 }) 254 .inner 255 } 256 257 fn notifications_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 258 let mut selected_option: Option<AddColumnResponse> = None; 259 for column_option_data in self.get_notifications_options(ui) { 260 let option = column_option_data.option.clone(); 261 if self.column_option_ui(ui, column_option_data).clicked() { 262 selected_option = Some(option.take_as_response(self.cur_account)); 263 } 264 265 ui.add(Separator::default().spacing(0.0)); 266 } 267 268 selected_option 269 } 270 271 fn external_notification_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 272 self.external_search_ui(ui, "external_notif", notification_column_response) 273 } 274 275 fn algo_last_per_pk_ui( 276 &mut self, 277 ui: &mut Ui, 278 deck_author: Pubkey, 279 ) -> Option<AddColumnResponse> { 280 let algo_option = ColumnOptionData { 281 title: tr!(self.i18n, "Contact List", "Title for contact list column"), 282 description: tr!( 283 self.i18n, 284 "Source the last note for each user in your contact list", 285 "Description for contact list column" 286 ), 287 icon: app_images::home_image(), 288 option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Decided( 289 ListKind::contact_list(deck_author), 290 ))), 291 }; 292 293 let option = algo_option.option.clone(); 294 self.column_option_ui(ui, algo_option) 295 .clicked() 296 .then(|| option.take_as_response(self.cur_account)) 297 } 298 299 fn people_list_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 300 // Initialize the cache on first visit — subscribes locally and to relays 301 if self.people_lists.is_none() { 302 let txn = Transaction::new(self.ndb).expect("txn"); 303 let filter = Filter::new() 304 .authors([self.cur_account.key.pubkey.bytes()]) 305 .kinds([30000]) 306 .limit(50) 307 .build(); 308 *self.people_lists = 309 notedeck::Nip51SetCache::new_local(self.ndb, &txn, self.unknown_ids, vec![filter]); 310 } 311 312 // Poll for newly arrived notes each frame 313 if let Some(cache) = self.people_lists.as_mut() { 314 cache.poll_for_notes(self.ndb, self.unknown_ids); 315 } 316 317 padding(16.0, ui, |ui| { 318 // Always show "New List" button at the top 319 if ui.button("+ New List").clicked() { 320 return Some(AddColumnResponse::CreatePeopleList); 321 } 322 323 ui.add_space(8.0); 324 325 let Some(cache) = self.people_lists.as_ref() else { 326 ui.label("Loading lists from relays..."); 327 return None; 328 }; 329 330 if cache.is_empty() { 331 ui.label("No people lists found."); 332 return None; 333 } 334 335 let mut response = None; 336 for set in cache.iter() { 337 let title = set.title.as_deref().unwrap_or(&set.identifier); 338 let label = format!("{} ({} members)", title, set.pks.len()); 339 340 if ui.button(&label).clicked() { 341 response = Some(AddColumnResponse::Timeline(TimelineKind::people_list( 342 self.cur_account.key.pubkey, 343 set.identifier.clone(), 344 ))); 345 } 346 347 ui.add(Separator::default().spacing(4.0)); 348 } 349 350 response 351 }) 352 .inner 353 } 354 355 fn algo_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 356 let algo_option = ColumnOptionData { 357 title: tr!( 358 self.i18n, 359 "Last Note per User", 360 "Title for last note per user column" 361 ), 362 description: tr!( 363 self.i18n, 364 "Show the last note for each user from a list", 365 "Description for last note per user column" 366 ), 367 icon: app_images::algo_image(), 368 option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)), 369 }; 370 371 let option = algo_option.option.clone(); 372 self.column_option_ui(ui, algo_option) 373 .clicked() 374 .then(|| option.take_as_response(self.cur_account)) 375 } 376 377 fn individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 378 let mut selected_option: Option<AddColumnResponse> = None; 379 for column_option_data in self.get_individual_options() { 380 let option = column_option_data.option.clone(); 381 if self.column_option_ui(ui, column_option_data).clicked() { 382 selected_option = Some(option.take_as_response(self.cur_account)); 383 } 384 385 ui.add(Separator::default().spacing(0.0)); 386 } 387 388 selected_option 389 } 390 391 fn external_individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 392 self.external_search_ui(ui, "external_individual", individual_column_response) 393 } 394 395 fn external_search_ui( 396 &mut self, 397 ui: &mut Ui, 398 id_salt: &str, 399 to_response: fn(Pubkey, &UserAccount) -> AddColumnResponse, 400 ) -> Option<AddColumnResponse> { 401 let id = ui.id().with(id_salt); 402 403 ui.add_space(8.0); 404 let hint = tr!( 405 self.i18n, 406 "Search profiles or enter nip05 address...", 407 "Placeholder for profile search input" 408 ); 409 let query_buf = self.id_string_map.entry(id).or_default(); 410 ui.add(search_input_box(query_buf, &hint)); 411 ui.add_space(12.0); 412 413 let query = self 414 .id_string_map 415 .get(&id) 416 .map(|s| s.trim().to_string()) 417 .unwrap_or_default(); 418 419 if query.contains('@') { 420 nip05_profile_ui( 421 ui, 422 id, 423 &query, 424 self.key_state_map, 425 self.ndb, 426 self.img_cache, 427 self.jobs, 428 self.i18n, 429 self.cur_account, 430 to_response, 431 ) 432 } else if query.is_empty() { 433 self.key_state_map.remove(&id); 434 contacts_list_column_ui( 435 ui, 436 self.contacts, 437 self.jobs, 438 self.ndb, 439 self.img_cache, 440 self.i18n, 441 &SelectionHandler { 442 cur_account: self.cur_account, 443 to_response, 444 }, 445 ) 446 } else { 447 self.key_state_map.remove(&id); 448 profile_search_column_ui( 449 ui, 450 &query, 451 self.ndb, 452 self.contacts, 453 self.img_cache, 454 self.jobs, 455 self.i18n, 456 self.cur_account, 457 to_response, 458 ) 459 } 460 } 461 462 fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response { 463 let icon_padding = 8.0; 464 let min_icon_width = 32.0; 465 let height_padding = 12.0; 466 let inter_text_padding = 4.0; // Padding between title and description 467 let max_width = ui.available_width(); 468 let title_style = NotedeckTextStyle::Body; 469 let desc_style = NotedeckTextStyle::Button; 470 let title_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &title_style); 471 let desc_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &desc_style); 472 473 let max_height = { 474 let max_wrap_width = 475 max_width - ((icon_padding * 2.0) + (min_icon_width * ICON_EXPANSION_MULTIPLE)); 476 let title_max_font = FontId::new( 477 title_min_font_size * ICON_EXPANSION_MULTIPLE, 478 title_style.font_family(), 479 ); 480 let desc_max_font = FontId::new( 481 desc_min_font_size * ICON_EXPANSION_MULTIPLE, 482 desc_style.font_family(), 483 ); 484 let max_desc_galley = ui.fonts(|f| { 485 f.layout( 486 data.description.to_string(), 487 desc_max_font, 488 ui.style().visuals.noninteractive().fg_stroke.color, 489 max_wrap_width, 490 ) 491 }); 492 let max_title_galley = ui.fonts(|f| { 493 f.layout( 494 data.title.to_string(), 495 title_max_font, 496 Color32::WHITE, 497 max_wrap_width, 498 ) 499 }); 500 501 let desc_font_max_size = max_desc_galley.rect.height(); 502 let title_font_max_size = max_title_galley.rect.height(); 503 title_font_max_size + inter_text_padding + desc_font_max_size + (2.0 * height_padding) 504 }; 505 506 let title = data.title.clone(); 507 let helper = AnimationHelper::new(ui, title.clone(), vec2(max_width, max_height)); 508 let animation_rect = helper.get_animation_rect(); 509 510 let cur_icon_width = helper.scale_1d_pos(min_icon_width); 511 let painter = ui.painter_at(animation_rect); 512 513 let cur_icon_size = vec2(cur_icon_width, cur_icon_width); 514 let cur_icon_x_pos = animation_rect.left() + icon_padding + (cur_icon_width / 2.0); 515 516 let title_cur_font = FontId::new( 517 helper.scale_1d_pos(title_min_font_size), 518 title_style.font_family(), 519 ); 520 let desc_cur_font = FontId::new( 521 helper.scale_1d_pos(desc_min_font_size), 522 desc_style.font_family(), 523 ); 524 525 let wrap_width = max_width - (cur_icon_width + (icon_padding * 2.0)); 526 let text_color = ui.style().visuals.text_color(); 527 let fallback_color = ui.style().visuals.noninteractive().fg_stroke.color; 528 529 let title_galley = painter.layout( 530 data.title.to_string(), 531 title_cur_font, 532 text_color, 533 wrap_width, 534 ); 535 let desc_galley = painter.layout( 536 data.description.to_string(), 537 desc_cur_font, 538 fallback_color, 539 wrap_width, 540 ); 541 542 let total_content_height = 543 title_galley.rect.height() + inter_text_padding + desc_galley.rect.height(); 544 let cur_height_padding = (animation_rect.height() - total_content_height) / 2.0; 545 let corner_x_pos = cur_icon_x_pos + (cur_icon_width / 2.0) + icon_padding; 546 let title_corner_pos = Pos2::new(corner_x_pos, animation_rect.top() + cur_height_padding); 547 let desc_corner_pos = Pos2::new( 548 corner_x_pos, 549 title_corner_pos.y + title_galley.rect.height() + inter_text_padding, 550 ); 551 552 let icon_cur_y = animation_rect.top() + cur_height_padding + (total_content_height / 2.0); 553 let icon_img = data.icon.fit_to_exact_size(cur_icon_size); 554 let icon_rect = Rect::from_center_size(pos2(cur_icon_x_pos, icon_cur_y), cur_icon_size); 555 556 icon_img.paint_at(ui, icon_rect); 557 painter.galley(title_corner_pos, title_galley, text_color); 558 painter.galley(desc_corner_pos, desc_galley, fallback_color); 559 560 helper.take_animation_response() 561 } 562 563 fn get_base_options(&mut self, ui: &mut Ui) -> Vec<ColumnOptionData> { 564 let mut vec = Vec::new(); 565 vec.push(ColumnOptionData { 566 title: tr!(self.i18n, "Home", "Title for Home column"), 567 description: tr!( 568 self.i18n, 569 "See notes from your contacts", 570 "Description for Home column" 571 ), 572 icon: app_images::home_image(), 573 option: AddColumnOption::Contacts(if self.cur_account.key.secret_key.is_some() { 574 PubkeySource::DeckAuthor 575 } else { 576 PubkeySource::Explicit(self.cur_account.key.pubkey) 577 }), 578 }); 579 vec.push(ColumnOptionData { 580 title: tr!(self.i18n, "Notifications", "Title for notifications column"), 581 description: tr!( 582 self.i18n, 583 "Stay up to date with notifications and mentions", 584 "Description for notifications column" 585 ), 586 icon: app_images::notifications_image(ui.visuals().dark_mode), 587 option: AddColumnOption::UndecidedNotification, 588 }); 589 vec.push(ColumnOptionData { 590 title: tr!(self.i18n, "Universe", "Title for universe column"), 591 description: tr!( 592 self.i18n, 593 "See the whole nostr universe", 594 "Description for universe column" 595 ), 596 icon: app_images::universe_image(), 597 option: AddColumnOption::Universe, 598 }); 599 vec.push(ColumnOptionData { 600 title: tr!(self.i18n, "Hashtags", "Title for hashtags column"), 601 description: tr!( 602 self.i18n, 603 "Stay up to date with a certain hashtag", 604 "Description for hashtags column" 605 ), 606 icon: app_images::hashtag_image(), 607 option: AddColumnOption::UndecidedHashtag, 608 }); 609 vec.push(ColumnOptionData { 610 title: tr!(self.i18n, "Individual", "Title for individual user column"), 611 description: tr!( 612 self.i18n, 613 "Stay up to date with someone's notes & replies", 614 "Description for individual user column" 615 ), 616 icon: app_images::add_column_individual_image(), 617 option: AddColumnOption::UndecidedIndividual, 618 }); 619 vec.push(ColumnOptionData { 620 title: tr!(self.i18n, "People List", "Title for people list column"), 621 description: tr!( 622 self.i18n, 623 "See notes from a NIP-51 people list", 624 "Description for people list column" 625 ), 626 icon: app_images::home_image(), 627 option: AddColumnOption::UndecidedPeopleList, 628 }); 629 vec.push(ColumnOptionData { 630 title: tr!(self.i18n, "Algo", "Title for algorithmic feeds column"), 631 description: tr!( 632 self.i18n, 633 "Algorithmic feeds to aid in note discovery", 634 "Description for algorithmic feeds column" 635 ), 636 icon: app_images::algo_image(), 637 option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)), 638 }); 639 640 vec 641 } 642 643 fn get_notifications_options(&mut self, ui: &mut Ui) -> Vec<ColumnOptionData> { 644 let mut vec = Vec::new(); 645 646 let source = if self.cur_account.key.secret_key.is_some() { 647 PubkeySource::DeckAuthor 648 } else { 649 PubkeySource::Explicit(self.cur_account.key.pubkey) 650 }; 651 652 vec.push(ColumnOptionData { 653 title: tr!( 654 self.i18n, 655 "Your Notifications", 656 "Title for your notifications column" 657 ), 658 description: tr!( 659 self.i18n, 660 "Stay up to date with your notifications and mentions", 661 "Description for your notifications column" 662 ), 663 icon: app_images::notifications_image(ui.visuals().dark_mode), 664 option: AddColumnOption::Notification(source), 665 }); 666 667 vec.push(ColumnOptionData { 668 title: tr!( 669 self.i18n, 670 "Someone else's Notifications", 671 "Title for someone else's notifications column" 672 ), 673 description: tr!( 674 self.i18n, 675 "Stay up to date with someone else's notifications and mentions", 676 "Description for someone else's notifications column" 677 ), 678 icon: app_images::notifications_image(ui.visuals().dark_mode), 679 option: AddColumnOption::ExternalNotification, 680 }); 681 682 vec 683 } 684 685 fn get_individual_options(&mut self) -> Vec<ColumnOptionData> { 686 let mut vec = Vec::new(); 687 688 let source = if self.cur_account.key.secret_key.is_some() { 689 PubkeySource::DeckAuthor 690 } else { 691 PubkeySource::Explicit(self.cur_account.key.pubkey) 692 }; 693 694 vec.push(ColumnOptionData { 695 title: tr!(self.i18n, "Your Notes", "Title for your notes column"), 696 description: tr!( 697 self.i18n, 698 "Keep track of your notes & replies", 699 "Description for your notes column" 700 ), 701 icon: app_images::add_column_individual_image(), 702 option: AddColumnOption::Individual(source), 703 }); 704 705 vec.push(ColumnOptionData { 706 title: tr!( 707 self.i18n, 708 "Someone else's Notes", 709 "Title for someone else's notes column" 710 ), 711 description: tr!( 712 self.i18n, 713 "Stay up to date with someone else's notes & replies", 714 "Description for someone else's notes column" 715 ), 716 icon: app_images::add_column_individual_image(), 717 option: AddColumnOption::ExternalIndividual, 718 }); 719 720 vec 721 } 722 } 723 724 fn add_column_button(i18n: &mut Localization) -> impl Widget { 725 let label = tr!(i18n, "Add", "Label for add column button"); 726 let color = notedeck_ui::colors::PINK; 727 move |ui: &mut egui::Ui| styled_button(label.as_str(), color).ui(ui) 728 } 729 730 fn individual_column_response(pubkey: Pubkey, cur_account: &UserAccount) -> AddColumnResponse { 731 AddColumnOption::Individual(PubkeySource::Explicit(pubkey)).take_as_response(cur_account) 732 } 733 734 fn notification_column_response(pubkey: Pubkey, cur_account: &UserAccount) -> AddColumnResponse { 735 AddColumnOption::Notification(PubkeySource::Explicit(pubkey)).take_as_response(cur_account) 736 } 737 738 #[allow(clippy::too_many_arguments)] 739 fn nip05_profile_ui( 740 ui: &mut Ui, 741 id: egui::Id, 742 query: &str, 743 key_state_map: &mut HashMap<Id, AcquireKeyState>, 744 ndb: &Ndb, 745 img_cache: &mut Images, 746 jobs: &MediaJobSender, 747 i18n: &mut Localization, 748 cur_account: &UserAccount, 749 to_response: fn(Pubkey, &UserAccount) -> AddColumnResponse, 750 ) -> Option<AddColumnResponse> { 751 let key_state = key_state_map.entry(id).or_default(); 752 753 // Sync the search input into AcquireKeyState's buffer 754 let buf = key_state.input_buffer(); 755 if *buf != query { 756 buf.clear(); 757 buf.push_str(query); 758 key_state.apply_acquire(); 759 } 760 761 key_state.loading_and_error_ui(ui, i18n); 762 763 let resp = if let Some(keypair) = key_state.get_login_keypair() { 764 let txn = Transaction::new(ndb).expect("txn"); 765 let profile = ndb.get_profile_by_pubkey(&txn, keypair.pubkey.bytes()).ok(); 766 767 profile_row(ui, profile.as_ref(), false, img_cache, jobs, i18n) 768 .then(|| to_response(keypair.pubkey, cur_account)) 769 } else { 770 None 771 }; 772 773 if resp.is_some() { 774 key_state_map.remove(&id); 775 } 776 777 resp 778 } 779 780 #[allow(clippy::too_many_arguments)] 781 fn contacts_list_column_ui( 782 ui: &mut Ui, 783 contacts: &ContactState, 784 jobs: &MediaJobSender, 785 ndb: &Ndb, 786 img_cache: &mut Images, 787 i18n: &mut Localization, 788 handler: &SelectionHandler<'_>, 789 ) -> Option<AddColumnResponse> { 790 let ContactState::Received { 791 contacts: contact_set, 792 .. 793 } = contacts 794 else { 795 return None; 796 }; 797 798 let txn = Transaction::new(ndb).expect("txn"); 799 let resp = ContactsListView::new(contact_set, jobs, ndb, img_cache, &txn, i18n).ui(ui); 800 801 resp.output.map(|a| match a { 802 notedeck_ui::ContactsListAction::Select(pubkey) => handler.response(pubkey), 803 }) 804 } 805 806 #[allow(clippy::too_many_arguments)] 807 fn profile_search_column_ui( 808 ui: &mut Ui, 809 query: &str, 810 ndb: &Ndb, 811 contacts: &ContactState, 812 img_cache: &mut Images, 813 jobs: &MediaJobSender, 814 i18n: &mut Localization, 815 cur_account: &UserAccount, 816 to_response: fn(Pubkey, &UserAccount) -> AddColumnResponse, 817 ) -> Option<AddColumnResponse> { 818 let txn = Transaction::new(ndb).expect("txn"); 819 let results = search_profiles(ndb, &txn, query, contacts, 128); 820 821 if results.is_empty() { 822 ui.add_space(20.0); 823 ui.label( 824 RichText::new(tr!( 825 i18n, 826 "No profiles found", 827 "Shown when profile search returns no results" 828 )) 829 .weak(), 830 ); 831 return None; 832 } 833 834 let mut action = None; 835 egui::ScrollArea::vertical().show(ui, |ui| { 836 for result in &results { 837 let profile = ndb.get_profile_by_pubkey(&txn, &result.pk).ok(); 838 if profile_row( 839 ui, 840 profile.as_ref(), 841 result.is_contact, 842 img_cache, 843 jobs, 844 i18n, 845 ) { 846 action = Some(to_response(Pubkey::new(result.pk), cur_account)); 847 } 848 } 849 }); 850 action 851 } 852 853 /* 854 pub(crate) fn sized_button(text: &str) -> impl Widget + '_ { 855 move |ui: &mut egui::Ui| -> egui::Response { 856 let painter = ui.painter(); 857 let galley = painter.layout( 858 text.to_owned(), 859 NotedeckTextStyle::Body.get_font_id(ui.ctx()), 860 Color32::WHITE, 861 ui.available_width(), 862 ); 863 864 ui.add_sized( 865 galley.rect.expand2(vec2(16.0, 8.0)).size(), 866 egui::Button::new(galley) 867 .corner_radius(8.0) 868 .fill(notedeck_ui::colors::PINK), 869 ) 870 } 871 } 872 */ 873 874 struct ColumnOptionData { 875 title: String, 876 description: String, 877 icon: Image<'static>, 878 option: AddColumnOption, 879 } 880 881 /// Attach a new timeline column by building and initializing its timeline state. 882 fn attach_timeline_column( 883 app: &mut Damus, 884 ctx: &mut AppContext<'_>, 885 col: usize, 886 timeline_kind: TimelineKind, 887 ) -> bool { 888 let account_pk = *ctx.accounts.selected_account_pubkey(); 889 let already_open_for_account = app 890 .timeline_cache 891 .get(&timeline_kind) 892 .is_some_and(|timeline| timeline.subscription.dependers(&account_pk) > 0); 893 894 if already_open_for_account { 895 if let Some(timeline) = app.timeline_cache.get_mut(&timeline_kind) { 896 timeline.subscription.increment(account_pk); 897 } 898 899 app.columns_mut(ctx.i18n, ctx.accounts) 900 .column_mut(col) 901 .router_mut() 902 .route_to_replaced(Route::timeline(timeline_kind)); 903 return true; 904 } 905 906 let txn = Transaction::new(ctx.ndb).expect("txn"); 907 let mut timeline = if let Some(timeline) = timeline_kind.clone().into_timeline(&txn, ctx.ndb) { 908 timeline 909 } else { 910 error!("Could not convert column response to timeline"); 911 return false; 912 }; 913 914 let mut scoped_subs = ctx.remote.scoped_subs(ctx.accounts); 915 crate::timeline::setup_new_timeline( 916 &mut timeline, 917 ctx.ndb, 918 &txn, 919 &mut scoped_subs, 920 app.options.contains(AppOptions::SinceOptimize), 921 ctx.accounts, 922 ); 923 924 let route_kind = timeline.kind.clone(); 925 app.columns_mut(ctx.i18n, ctx.accounts) 926 .column_mut(col) 927 .router_mut() 928 .route_to_replaced(Route::timeline(route_kind.clone())); 929 app.timeline_cache.insert( 930 route_kind, 931 *ctx.accounts.selected_account_pubkey(), 932 timeline, 933 ); 934 935 true 936 } 937 938 pub fn render_add_column_routes( 939 ui: &mut egui::Ui, 940 app: &mut Damus, 941 ctx: &mut AppContext<'_>, 942 col: usize, 943 route: &AddColumnRoute, 944 ) { 945 // Hashtag and CreatePeopleList are handled separately because they 946 // borrow ViewState fields directly (conflicting with AddColumnView) 947 let resp = match route { 948 AddColumnRoute::Hashtag => hashtag_ui(ui, ctx.i18n, &mut app.view_state.id_string_map), 949 AddColumnRoute::CreatePeopleList => create_people_list_ui(ui, app, ctx), 950 _ => { 951 let account = ctx.accounts.get_selected_account(); 952 let contacts = account.data.contacts.get_state(); 953 let mut add_column_view = AddColumnView::new( 954 &mut app.view_state.id_state_map, 955 &mut app.view_state.id_string_map, 956 ctx.ndb, 957 ctx.img_cache, 958 account, 959 contacts, 960 ctx.i18n, 961 ctx.media_jobs.sender(), 962 ctx.unknown_ids, 963 &mut app.view_state.people_lists, 964 ); 965 match route { 966 AddColumnRoute::Base => add_column_view.ui(ui), 967 AddColumnRoute::Algo(r) => match r { 968 AddAlgoRoute::Base => add_column_view.algo_ui(ui), 969 AddAlgoRoute::LastPerPubkey => { 970 add_column_view.algo_last_per_pk_ui(ui, account.key.pubkey) 971 } 972 }, 973 AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), 974 AddColumnRoute::ExternalNotification => { 975 add_column_view.external_notification_ui(ui) 976 } 977 AddColumnRoute::UndecidedIndividual => add_column_view.individual_ui(ui), 978 AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui), 979 AddColumnRoute::PeopleList => add_column_view.people_list_ui(ui), 980 AddColumnRoute::Hashtag | AddColumnRoute::CreatePeopleList => unreachable!(), 981 } 982 } 983 }; 984 985 if let Some(resp) = resp { 986 match resp { 987 AddColumnResponse::Timeline(timeline_kind) => { 988 let _ = attach_timeline_column(app, ctx, col, timeline_kind); 989 } 990 991 AddColumnResponse::Algo(algo_option) => match algo_option { 992 // If we are undecided, we simply route to the LastPerPubkey 993 // algo route selection 994 AlgoOption::LastPerPubkey(Decision::Undecided) => { 995 app.columns_mut(ctx.i18n, ctx.accounts) 996 .column_mut(col) 997 .router_mut() 998 .route_to(Route::AddColumn(AddColumnRoute::Algo( 999 AddAlgoRoute::LastPerPubkey, 1000 ))); 1001 } 1002 1003 // We have a decision on where we want the last per pubkey 1004 // source to be, so let's create a timeline from that and 1005 // add it to our list of timelines 1006 AlgoOption::LastPerPubkey(Decision::Decided(list_kind)) => { 1007 if !attach_timeline_column( 1008 app, 1009 ctx, 1010 col, 1011 TimelineKind::last_per_pubkey(list_kind.clone()), 1012 ) { 1013 // we couldn't fetch the timeline yet... let's let 1014 // the user know ? 1015 1016 // TODO: spin off the list search here instead 1017 1018 ui.label(format!("error: could not find {list_kind:?}")); 1019 } 1020 } 1021 }, 1022 1023 AddColumnResponse::UndecidedNotification => { 1024 app.columns_mut(ctx.i18n, ctx.accounts) 1025 .column_mut(col) 1026 .router_mut() 1027 .route_to(Route::AddColumn(AddColumnRoute::UndecidedNotification)); 1028 } 1029 AddColumnResponse::ExternalNotification => { 1030 app.columns_mut(ctx.i18n, ctx.accounts) 1031 .column_mut(col) 1032 .router_mut() 1033 .route_to(crate::route::Route::AddColumn( 1034 AddColumnRoute::ExternalNotification, 1035 )); 1036 } 1037 AddColumnResponse::Hashtag => { 1038 app.columns_mut(ctx.i18n, ctx.accounts) 1039 .column_mut(col) 1040 .router_mut() 1041 .route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag)); 1042 } 1043 AddColumnResponse::UndecidedIndividual => { 1044 app.columns_mut(ctx.i18n, ctx.accounts) 1045 .column_mut(col) 1046 .router_mut() 1047 .route_to(crate::route::Route::AddColumn( 1048 AddColumnRoute::UndecidedIndividual, 1049 )); 1050 } 1051 AddColumnResponse::ExternalIndividual => { 1052 app.columns_mut(ctx.i18n, ctx.accounts) 1053 .column_mut(col) 1054 .router_mut() 1055 .route_to(crate::route::Route::AddColumn( 1056 AddColumnRoute::ExternalIndividual, 1057 )); 1058 } 1059 AddColumnResponse::PeopleList => { 1060 app.columns_mut(ctx.i18n, ctx.accounts) 1061 .column_mut(col) 1062 .router_mut() 1063 .route_to(crate::route::Route::AddColumn(AddColumnRoute::PeopleList)); 1064 } 1065 AddColumnResponse::CreatePeopleList => { 1066 app.columns_mut(ctx.i18n, ctx.accounts) 1067 .column_mut(col) 1068 .router_mut() 1069 .route_to(crate::route::Route::AddColumn( 1070 AddColumnRoute::CreatePeopleList, 1071 )); 1072 } 1073 AddColumnResponse::FinishCreatePeopleList => { 1074 handle_create_people_list(app, ctx, col); 1075 } 1076 }; 1077 } 1078 } 1079 1080 fn handle_create_people_list(app: &mut Damus, ctx: &mut AppContext<'_>, col: usize) { 1081 let name_id = Id::new("create_people_list_name"); 1082 let name = app 1083 .view_state 1084 .id_string_map 1085 .get(&name_id) 1086 .cloned() 1087 .unwrap_or_default(); 1088 1089 if name.is_empty() { 1090 return; 1091 } 1092 1093 let members: Vec<Pubkey> = app 1094 .view_state 1095 .create_people_list 1096 .selected_members 1097 .iter() 1098 .copied() 1099 .collect(); 1100 1101 if members.is_empty() { 1102 return; 1103 } 1104 1105 let Some(kp) = ctx.accounts.selected_filled() else { 1106 error!("Cannot create people list: no signing key available"); 1107 return; 1108 }; 1109 1110 notedeck::send_people_list_event( 1111 ctx.ndb, 1112 &mut ctx.remote.publisher(ctx.accounts), 1113 kp, 1114 &name, 1115 &members, 1116 ); 1117 1118 // Reset the people_lists cache so it picks up the new list 1119 app.view_state.people_lists = None; 1120 1121 // Clear creation state 1122 app.view_state.id_string_map.remove(&name_id); 1123 let search_id = Id::new("create_people_list_search"); 1124 app.view_state.id_string_map.remove(&search_id); 1125 app.view_state.create_people_list.selected_members.clear(); 1126 1127 // Create the timeline column immediately 1128 let pubkey = ctx.accounts.get_selected_account().key.pubkey; 1129 let timeline_kind = TimelineKind::people_list(pubkey, name); 1130 let txn = Transaction::new(ctx.ndb).unwrap(); 1131 let Some(mut timeline) = timeline_kind.into_timeline(&txn, ctx.ndb) else { 1132 error!("Could not create timeline from people list"); 1133 return; 1134 }; 1135 1136 let mut scoped_subs = ctx.remote.scoped_subs(ctx.accounts); 1137 crate::timeline::setup_new_timeline( 1138 &mut timeline, 1139 ctx.ndb, 1140 &txn, 1141 &mut scoped_subs, 1142 app.options.contains(AppOptions::SinceOptimize), 1143 ctx.accounts, 1144 ); 1145 1146 app.columns_mut(ctx.i18n, ctx.accounts) 1147 .column_mut(col) 1148 .router_mut() 1149 .route_to_replaced(Route::timeline(timeline.kind.clone())); 1150 1151 app.timeline_cache.insert( 1152 timeline.kind.clone(), 1153 *ctx.accounts.selected_account_pubkey(), 1154 timeline, 1155 ); 1156 } 1157 1158 pub fn hashtag_ui( 1159 ui: &mut Ui, 1160 i18n: &mut Localization, 1161 id_string_map: &mut HashMap<Id, String>, 1162 ) -> Option<AddColumnResponse> { 1163 padding(16.0, ui, |ui| { 1164 let id = ui.id().with("hashtag)"); 1165 let text_buffer = id_string_map.entry(id).or_default(); 1166 1167 let text_edit = egui::TextEdit::singleline(text_buffer) 1168 .hint_text( 1169 RichText::new(tr!( 1170 i18n, 1171 "Enter the desired hashtags here (for multiple space-separated)", 1172 "Placeholder for hashtag input field" 1173 )) 1174 .text_style(NotedeckTextStyle::Body.text_style()), 1175 ) 1176 .vertical_align(Align::Center) 1177 .desired_width(f32::INFINITY) 1178 .min_size(Vec2::new(0.0, 40.0)) 1179 .margin(Margin::same(12)); 1180 ui.add(text_edit); 1181 1182 ui.add_space(8.0); 1183 1184 let mut handle_user_input = false; 1185 if ui.input(|i| i.key_released(egui::Key::Enter)) 1186 || ui 1187 .add_sized(egui::vec2(50.0, 40.0), add_column_button(i18n)) 1188 .clicked() 1189 { 1190 handle_user_input = true; 1191 } 1192 1193 if handle_user_input && !text_buffer.is_empty() { 1194 let resp = AddColumnResponse::Timeline(TimelineKind::Hashtag( 1195 text_buffer 1196 .split_whitespace() 1197 .filter(|s| !s.is_empty()) 1198 .map(|s| sanitize_hashtag(s).to_lowercase().to_string()) 1199 .collect::<Vec<_>>(), 1200 )); 1201 id_string_map.remove(&id); 1202 Some(resp) 1203 } else { 1204 None 1205 } 1206 }) 1207 .inner 1208 } 1209 1210 pub fn create_people_list_ui( 1211 ui: &mut Ui, 1212 app: &mut Damus, 1213 ctx: &mut AppContext<'_>, 1214 ) -> Option<AddColumnResponse> { 1215 let account = ctx.accounts.get_selected_account(); 1216 let contacts = account.data.contacts.get_state(); 1217 1218 padding(16.0, ui, |ui| { 1219 // Use Id::new so IDs are stable across UI contexts (not dependent on parent widget) 1220 let name_id = Id::new("create_people_list_name"); 1221 let name_buffer = app.view_state.id_string_map.entry(name_id).or_default(); 1222 1223 ui.label(RichText::new("List Name").text_style(NotedeckTextStyle::Body.text_style())); 1224 ui.add_space(4.0); 1225 let name_edit = egui::TextEdit::singleline(name_buffer) 1226 .hint_text( 1227 RichText::new("Enter list name...") 1228 .text_style(NotedeckTextStyle::Body.text_style()), 1229 ) 1230 .vertical_align(Align::Center) 1231 .desired_width(f32::INFINITY) 1232 .min_size(Vec2::new(0.0, 40.0)) 1233 .margin(Margin::same(12)); 1234 ui.add(name_edit); 1235 1236 ui.add_space(8.0); 1237 1238 // Selected members count 1239 let member_count = app.view_state.create_people_list.selected_members.len(); 1240 ui.label( 1241 RichText::new(format!("{} members selected", member_count)) 1242 .text_style(NotedeckTextStyle::Body.text_style()) 1243 .weak(), 1244 ); 1245 1246 ui.add_space(8.0); 1247 1248 // Search bar 1249 let search_id = Id::new("create_people_list_search"); 1250 let search_buffer = app.view_state.id_string_map.entry(search_id).or_default(); 1251 1252 ui.add(search_input_box(search_buffer, "Search profiles...")); 1253 1254 ui.add_space(8.0); 1255 1256 // Profile results area 1257 let txn = Transaction::new(ctx.ndb).expect("txn"); 1258 let search_query = app 1259 .view_state 1260 .id_string_map 1261 .get(&search_id) 1262 .cloned() 1263 .unwrap_or_default(); 1264 1265 ScrollArea::vertical().show(ui, |ui| { 1266 if search_query.is_empty() { 1267 // Show contacts 1268 if let ContactState::Received { 1269 contacts: contact_set, 1270 .. 1271 } = contacts 1272 { 1273 for pk in contact_set { 1274 let profile = ctx.ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok(); 1275 let is_selected = app 1276 .view_state 1277 .create_people_list 1278 .selected_members 1279 .contains(pk); 1280 1281 ui.horizontal(|ui| { 1282 let mut checked = is_selected; 1283 ui.checkbox(&mut checked, ""); 1284 let clicked = profile_row( 1285 ui, 1286 profile.as_ref(), 1287 false, 1288 ctx.img_cache, 1289 ctx.media_jobs.sender(), 1290 ctx.i18n, 1291 ); 1292 if clicked || checked != is_selected { 1293 if is_selected { 1294 app.view_state 1295 .create_people_list 1296 .selected_members 1297 .remove(pk); 1298 } else { 1299 app.view_state 1300 .create_people_list 1301 .selected_members 1302 .insert(*pk); 1303 } 1304 } 1305 }); 1306 } 1307 } else { 1308 ui.label(RichText::new("No contacts loaded yet.").weak()); 1309 } 1310 } else { 1311 // Show search results 1312 let results = search_profiles(ctx.ndb, &txn, &search_query, contacts, 128); 1313 1314 if results.is_empty() { 1315 ui.add_space(20.0); 1316 ui.label(RichText::new("No profiles found").weak()); 1317 } else { 1318 for result in &results { 1319 let pk = Pubkey::new(result.pk); 1320 let profile = ctx.ndb.get_profile_by_pubkey(&txn, &result.pk).ok(); 1321 let is_selected = app 1322 .view_state 1323 .create_people_list 1324 .selected_members 1325 .contains(&pk); 1326 1327 ui.horizontal(|ui| { 1328 let mut checked = is_selected; 1329 ui.checkbox(&mut checked, ""); 1330 let clicked = profile_row( 1331 ui, 1332 profile.as_ref(), 1333 result.is_contact, 1334 ctx.img_cache, 1335 ctx.media_jobs.sender(), 1336 ctx.i18n, 1337 ); 1338 if clicked || checked != is_selected { 1339 if is_selected { 1340 app.view_state 1341 .create_people_list 1342 .selected_members 1343 .remove(&pk); 1344 } else { 1345 app.view_state 1346 .create_people_list 1347 .selected_members 1348 .insert(pk); 1349 } 1350 } 1351 }); 1352 } 1353 } 1354 } 1355 }); 1356 1357 ui.add_space(8.0); 1358 1359 // Create button 1360 let name_text = app 1361 .view_state 1362 .id_string_map 1363 .get(&name_id) 1364 .cloned() 1365 .unwrap_or_default(); 1366 let can_create = !name_text.is_empty() && member_count > 0; 1367 1368 let create_btn = egui::Button::new("Create List"); 1369 let resp = ui.add_enabled(can_create, create_btn); 1370 if resp.clicked() { 1371 return Some(AddColumnResponse::FinishCreatePeopleList); 1372 } 1373 1374 None 1375 }) 1376 .inner 1377 } 1378 1379 fn sanitize_hashtag(raw_hashtag: &str) -> String { 1380 raw_hashtag 1381 .chars() 1382 .filter(|c| c.is_alphanumeric()) // keep letters and numbers only 1383 .collect() 1384 } 1385 1386 #[cfg(test)] 1387 mod tests { 1388 use super::*; 1389 1390 #[test] 1391 fn test_column_serialize() { 1392 use super::{AddAlgoRoute, AddColumnRoute}; 1393 1394 { 1395 let data_str = "column:algo_selection:last_per_pubkey"; 1396 let data = &data_str.split(":").collect::<Vec<&str>>(); 1397 let mut token_writer = TokenWriter::default(); 1398 let mut parser = TokenParser::new(data); 1399 let parsed = AddColumnRoute::parse_from_tokens(&mut parser).unwrap(); 1400 let expected = AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey); 1401 parsed.serialize_tokens(&mut token_writer); 1402 assert_eq!(expected, parsed); 1403 assert_eq!(token_writer.str(), data_str); 1404 } 1405 1406 { 1407 let data_str = "column"; 1408 let mut token_writer = TokenWriter::default(); 1409 let data: &[&str] = &[data_str]; 1410 let mut parser = TokenParser::new(data); 1411 let parsed = AddColumnRoute::parse_from_tokens(&mut parser).unwrap(); 1412 let expected = AddColumnRoute::Base; 1413 parsed.serialize_tokens(&mut token_writer); 1414 assert_eq!(expected, parsed); 1415 assert_eq!(token_writer.str(), data_str); 1416 } 1417 } 1418 }