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:
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};