notedeck

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

commit a1df94a383f8a01c0009cc4099ee89a9cc966f66
parent 2a74b75bd61cc47e4c870d38356a0f34df49d551
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 18 Feb 2026 13:35:21 -0800

add_column: replace manual key input with profile search

The "Someone else's Notes" column flow now uses the shared profile
search widget instead of requiring manual npub/hex/nip05 entry. Shows
contacts list when empty, search results when typing, and handles
nip05 addresses via async resolution.

Also adds parse_pubkey_query() to notedeck_ui for shared npub,
nprofile, and hex pubkey detection, used by both the main search bar
and the add-column flow. Adds Pubkey::from_nprofile_bech() to enostr.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/enostr/src/pubkey.rs | 7+++++++
Mcrates/notedeck_columns/src/ui/add_column.rs | 249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/notedeck_columns/src/ui/search/mod.rs | 10++++------
Mcrates/notedeck_ui/src/contacts_list.rs | 23+++++++++++++++++++++++
Mcrates/notedeck_ui/src/lib.rs | 4++--
5 files changed, 260 insertions(+), 33 deletions(-)

diff --git a/crates/enostr/src/pubkey.rs b/crates/enostr/src/pubkey.rs @@ -118,6 +118,13 @@ impl Pubkey { pub fn npub(&self) -> Option<String> { bech32::encode::<bech32::Bech32>(HRP_NPUB, &self.0).ok() } + + /// Parse a NIP-19 nprofile1 bech32 string and extract the public key. + pub fn from_nprofile_bech(bech: &str) -> Option<Self> { + use nostr::nips::nip19::{FromBech32, Nip19Profile}; + let nip19_profile = Nip19Profile::from_bech32(bech).ok()?; + Some(Pubkey::new(nip19_profile.public_key.to_bytes())) + } } impl fmt::Display for Pubkey { diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs @@ -18,13 +18,17 @@ use crate::{ }; use notedeck::{ - tr, AppContext, Images, Localization, MediaJobSender, NotedeckTextStyle, UserAccount, + tr, AppContext, ContactState, Images, Localization, MediaJobSender, NotedeckTextStyle, + UserAccount, }; use notedeck_ui::{anim::ICON_EXPANSION_MULTIPLE, app_images}; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; use crate::ui::widgets::styled_button; -use notedeck_ui::{anim::AnimationHelper, padding, ProfilePreview}; +use notedeck_ui::{ + anim::AnimationHelper, contacts_list::ContactsCollection, padding, profile_row, + search_input_box, search_profiles, ContactsListView, ProfilePreview, +}; pub enum AddColumnResponse { Timeline(TimelineKind), @@ -166,27 +170,34 @@ impl AddColumnOption { pub struct AddColumnView<'a> { key_state_map: &'a mut HashMap<Id, AcquireKeyState>, + id_string_map: &'a mut HashMap<Id, String>, ndb: &'a Ndb, img_cache: &'a mut Images, cur_account: &'a UserAccount, + contacts: &'a ContactState, i18n: &'a mut Localization, jobs: &'a MediaJobSender, } impl<'a> AddColumnView<'a> { + #[allow(clippy::too_many_arguments)] pub fn new( key_state_map: &'a mut HashMap<Id, AcquireKeyState>, + id_string_map: &'a mut HashMap<Id, String>, ndb: &'a Ndb, img_cache: &'a mut Images, cur_account: &'a UserAccount, + contacts: &'a ContactState, i18n: &'a mut Localization, jobs: &'a MediaJobSender, ) -> Self { Self { key_state_map, + id_string_map, ndb, img_cache, cur_account, + contacts, i18n, jobs, } @@ -299,9 +310,58 @@ impl<'a> AddColumnView<'a> { fn external_individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { let id = ui.id().with("external_individual"); - self.external_ui(ui, id, |pubkey| { - AddColumnOption::Individual(PubkeySource::Explicit(pubkey)) - }) + ui.add_space(8.0); + let hint = tr!( + self.i18n, + "Search profiles or enter nip05 address...", + "Placeholder for profile search input" + ); + let query_buf = self.id_string_map.entry(id).or_default(); + ui.add(search_input_box(query_buf, &hint)); + ui.add_space(12.0); + + let query = self + .id_string_map + .get(&id) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + if query.contains('@') { + nip05_profile_ui( + ui, + id, + &query, + self.key_state_map, + self.ndb, + self.img_cache, + self.jobs, + self.i18n, + self.cur_account, + ) + } else if query.is_empty() { + self.key_state_map.remove(&id); + contacts_list_column_ui( + ui, + self.contacts, + self.jobs, + self.ndb, + self.img_cache, + self.i18n, + self.cur_account, + ) + } else { + self.key_state_map.remove(&id); + profile_search_column_ui( + ui, + &query, + self.ndb, + self.contacts, + self.img_cache, + self.jobs, + self.i18n, + self.cur_account, + ) + } } fn external_ui( @@ -637,6 +697,135 @@ fn add_column_button(i18n: &mut Localization) -> impl Widget { move |ui: &mut egui::Ui| styled_button(label.as_str(), color).ui(ui) } +fn individual_column_response(pubkey: Pubkey, cur_account: &UserAccount) -> AddColumnResponse { + AddColumnOption::Individual(PubkeySource::Explicit(pubkey)).take_as_response(cur_account) +} + +#[allow(clippy::too_many_arguments)] +fn nip05_profile_ui( + ui: &mut Ui, + id: egui::Id, + query: &str, + key_state_map: &mut HashMap<Id, AcquireKeyState>, + ndb: &Ndb, + img_cache: &mut Images, + jobs: &MediaJobSender, + i18n: &mut Localization, + cur_account: &UserAccount, +) -> Option<AddColumnResponse> { + let key_state = key_state_map.entry(id).or_default(); + + // Sync the search input into AcquireKeyState's buffer + let buf = key_state.input_buffer(); + if *buf != query { + buf.clear(); + buf.push_str(query); + key_state.apply_acquire(); + } + + key_state.loading_and_error_ui(ui, i18n); + + let resp = if let Some(keypair) = key_state.get_login_keypair() { + let txn = Transaction::new(ndb).expect("txn"); + let profile = ndb.get_profile_by_pubkey(&txn, keypair.pubkey.bytes()).ok(); + + profile_row(ui, profile.as_ref(), false, img_cache, jobs, i18n) + .then(|| individual_column_response(keypair.pubkey, cur_account)) + } else { + None + }; + + if resp.is_some() { + key_state_map.remove(&id); + } + + resp +} + +fn contacts_list_column_ui( + ui: &mut Ui, + contacts: &ContactState, + jobs: &MediaJobSender, + ndb: &Ndb, + img_cache: &mut Images, + i18n: &mut Localization, + cur_account: &UserAccount, +) -> Option<AddColumnResponse> { + let ContactState::Received { + contacts: contact_set, + .. + } = contacts + else { + return None; + }; + + let txn = Transaction::new(ndb).expect("txn"); + let resp = ContactsListView::new( + ContactsCollection::Set(contact_set), + jobs, + ndb, + img_cache, + &txn, + i18n, + ) + .ui(ui); + + resp.output.map(|a| match a { + notedeck_ui::ContactsListAction::Select(pubkey) => { + individual_column_response(pubkey, cur_account) + } + }) +} + +#[allow(clippy::too_many_arguments)] +fn profile_search_column_ui( + ui: &mut Ui, + query: &str, + ndb: &Ndb, + contacts: &ContactState, + img_cache: &mut Images, + jobs: &MediaJobSender, + i18n: &mut Localization, + cur_account: &UserAccount, +) -> Option<AddColumnResponse> { + let txn = Transaction::new(ndb).expect("txn"); + let results = search_profiles(ndb, &txn, query, contacts, 128); + + if results.is_empty() { + ui.add_space(20.0); + ui.label( + RichText::new(tr!( + i18n, + "No profiles found", + "Shown when profile search returns no results" + )) + .weak(), + ); + return None; + } + + let mut action = None; + egui::ScrollArea::vertical().show(ui, |ui| { + for result in &results { + let profile = ndb.get_profile_by_pubkey(&txn, &result.pk).ok(); + if profile_row( + ui, + profile.as_ref(), + result.is_contact, + img_cache, + jobs, + i18n, + ) { + action = Some(individual_column_response( + Pubkey::new(result.pk), + cur_account, + )); + } + } + }); + action +} + /* pub(crate) fn sized_button(text: &str) -> impl Widget + '_ { move |ui: &mut egui::Ui| -> egui::Response { @@ -672,26 +861,36 @@ pub fn render_add_column_routes( col: usize, route: &AddColumnRoute, ) { - let mut add_column_view = AddColumnView::new( - &mut app.view_state.id_state_map, - ctx.ndb, - ctx.img_cache, - ctx.accounts.get_selected_account(), - ctx.i18n, - ctx.media_jobs.sender(), - ); - let resp = match route { - AddColumnRoute::Base => add_column_view.ui(ui), - AddColumnRoute::Algo(r) => match r { - AddAlgoRoute::Base => add_column_view.algo_ui(ui), - AddAlgoRoute::LastPerPubkey => add_column_view - .algo_last_per_pk_ui(ui, ctx.accounts.get_selected_account().key.pubkey), - }, - AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), - AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui), - AddColumnRoute::Hashtag => hashtag_ui(ui, ctx.i18n, &mut app.view_state.id_string_map), - AddColumnRoute::UndecidedIndividual => add_column_view.individual_ui(ui), - AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui), + // Handle hashtag separately since it borrows id_string_map directly + let resp = if matches!(route, AddColumnRoute::Hashtag) { + hashtag_ui(ui, ctx.i18n, &mut app.view_state.id_string_map) + } else { + let account = ctx.accounts.get_selected_account(); + let contacts = account.data.contacts.get_state(); + let mut add_column_view = AddColumnView::new( + &mut app.view_state.id_state_map, + &mut app.view_state.id_string_map, + ctx.ndb, + ctx.img_cache, + account, + contacts, + ctx.i18n, + ctx.media_jobs.sender(), + ); + match route { + AddColumnRoute::Base => add_column_view.ui(ui), + AddColumnRoute::Algo(r) => match r { + AddAlgoRoute::Base => add_column_view.algo_ui(ui), + AddAlgoRoute::LastPerPubkey => { + add_column_view.algo_last_per_pk_ui(ui, account.key.pubkey) + } + }, + AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), + AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui), + AddColumnRoute::Hashtag => unreachable!(), + AddColumnRoute::UndecidedIndividual => add_column_view.individual_ui(ui), + AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui), + } }; if let Some(resp) = resp { diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -16,8 +16,8 @@ use notedeck::{ use notedeck_ui::{ context_menu::{input_context, PasteBehavior}, icons::search_icon, - padding, profile_row_widget, search_input_frame, search_profiles, NoteOptions, - ProfileRowOptions, SEARCH_INPUT_HEIGHT, + padding, parse_pubkey_query, profile_row_widget, search_input_frame, search_profiles, + NoteOptions, ProfileRowOptions, SEARCH_INPUT_HEIGHT, }; use std::time::{Duration, Instant}; use tracing::{error, info, warn}; @@ -608,10 +608,8 @@ impl SearchType { if let Some(noteid) = NoteId::from_bech(query) { return SearchType::NoteId(noteid); } - } else if query.len() == 63 && query.starts_with("npub1") { - if let Ok(pk) = Pubkey::try_from_bech32_string(query, false) { - return SearchType::Profile(pk); - } + } else if let Some(pk) = parse_pubkey_query(query) { + return SearchType::Profile(Pubkey::new(pk)); } else if query.chars().nth(0).is_some_and(|c| c == '#') { if let Some(hashtag) = query.get(1..) { return SearchType::Hashtag(hashtag.to_string()); diff --git a/crates/notedeck_ui/src/contacts_list.rs b/crates/notedeck_ui/src/contacts_list.rs @@ -248,8 +248,25 @@ pub struct ProfileSearchResult { pub is_contact: bool, } +/// Try to parse the query as a pubkey identifier (npub, nprofile, or hex). +/// Returns the pubkey bytes if successful. +pub fn parse_pubkey_query(query: &str) -> Option<[u8; 32]> { + if query.starts_with("npub1") { + Pubkey::try_from_bech32_string(query, false) + .ok() + .map(|pk| *pk.bytes()) + } else if query.starts_with("nprofile1") { + Pubkey::from_nprofile_bech(query).map(|pk| *pk.bytes()) + } else if query.len() == 64 && query.chars().all(|c| c.is_ascii_hexdigit()) { + Pubkey::from_hex(query).ok().map(|pk| *pk.bytes()) + } else { + None + } +} + /// Searches for profiles matching `query`, prioritizing contacts first and deduplicating. /// Contacts that match appear first, followed by non-contact results. +/// Also handles npub and nprofile bech32 strings as direct pubkey lookups. /// Returns up to `max_results` matches. pub fn search_profiles( ndb: &Ndb, @@ -263,6 +280,12 @@ pub fn search_profiles( _ => None, }; + // Check if the query is an npub or nprofile + if let Some(pk) = parse_pubkey_query(query) { + let is_contact = contacts_set.is_some_and(|c| c.contains(&pk)); + return vec![ProfileSearchResult { pk, is_contact }]; + } + // Get ndb search results and partition into contacts and non-contacts let mut contact_results: Vec<ProfileSearchResult> = Vec::new(); let mut other_results: Vec<ProfileSearchResult> = Vec::new(); diff --git a/crates/notedeck_ui/src/lib.rs b/crates/notedeck_ui/src/lib.rs @@ -18,8 +18,8 @@ pub mod widgets; pub use anim::{rolling_number, AnimationHelper, PulseAlpha}; pub use contacts_list::{ - profile_row, profile_row_widget, search_profiles, ContactsListAction, ContactsListView, - ProfileRowOptions, ProfileSearchResult, + parse_pubkey_query, profile_row, profile_row_widget, search_profiles, ContactsListAction, + ContactsListView, ProfileRowOptions, ProfileSearchResult, }; pub use debug::debug_slider; pub use icons::{expanding_button, ICON_EXPANSION_MULTIPLE, ICON_WIDTH};