notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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 }