notedeck

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

commit 3b7d54c9c88577d8538a69ba3490bbc994503eb4
parent 015d502b981d93330d93f39c3f709b2a451ebcf8
Author: William Casarin <jb55@jb55.com>
Date:   Tue,  3 Feb 2026 18:26:57 -0800

messages: add profile search to create conversation UI

Add a search input field that allows users to search for any profile
when starting a new DM, not just their contacts. When the search is
empty, the contacts list is shown. When typing, search results from
nostrdb are displayed with contacts prioritized and marked with a
"Contact" badge.

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

Diffstat:
Mcrates/notedeck_columns/src/nav.rs | 1+
Mcrates/notedeck_messages/src/cache/mod.rs | 2+-
Mcrates/notedeck_messages/src/cache/state.rs | 9++++++++-
Mcrates/notedeck_messages/src/ui/create_convo.rs | 258++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/notedeck_messages/src/ui/nav.rs | 11++++++++++-
Mcrates/notedeck_ui/src/contacts_list.rs | 114++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/notedeck_ui/src/lib.rs | 2+-
7 files changed, 325 insertions(+), 72 deletions(-)

diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -1016,6 +1016,7 @@ fn render_nav_body( note_context.ndb, note_context.img_cache, &txn, + note_context.i18n, ) .ui(ui) .map_output(|action| match action { diff --git a/crates/notedeck_messages/src/cache/mod.rs b/crates/notedeck_messages/src/cache/mod.rs @@ -10,4 +10,4 @@ pub use message_store::{MessageStore, NotePkg}; pub use registry::{ ConversationId, ConversationIdentifier, ConversationIdentifierUnowned, ParticipantSetUnowned, }; -pub use state::{ConversationState, ConversationStates}; +pub use state::{ConversationState, ConversationStates, CreateConvoState}; diff --git a/crates/notedeck_messages/src/cache/state.rs b/crates/notedeck_messages/src/cache/state.rs @@ -4,11 +4,17 @@ use crate::cache::ConversationId; use egui_virtual_list::VirtualList; use notedeck::NoteRef; -/// Keep track of the UI state for conversations. Meant to be mutably accessed by UI +/// Search state for the create conversation UI +#[derive(Default)] +pub struct CreateConvoState { + pub query: String, +} + #[derive(Default)] pub struct ConversationStates { pub cache: HashMap<ConversationId, ConversationState>, pub convos_list: VirtualList, + pub create_convo: CreateConvoState, } impl ConversationStates { @@ -18,6 +24,7 @@ impl ConversationStates { Self { cache: Default::default(), convos_list, + create_convo: Default::default(), } } pub fn get_or_insert(&mut self, id: ConversationId) -> &mut ConversationState { diff --git a/crates/notedeck_messages/src/ui/create_convo.rs b/crates/notedeck_messages/src/ui/create_convo.rs @@ -1,8 +1,17 @@ -use egui::{Label, RichText}; +use std::collections::HashSet; + +use egui::{Align, Color32, CornerRadius, Label, RichText, Stroke, TextEdit}; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; -use notedeck::{tr, ContactState, Images, Localization, MediaJobSender, NotedeckTextStyle}; -use notedeck_ui::{contacts_list::ContactsCollection, ContactsListView}; +use notedeck::{ + name::get_display_name, tr, ContactState, Images, Localization, MediaJobSender, + NotedeckTextStyle, +}; +use notedeck_ui::{ + contacts_list::ContactsCollection, icons::search_icon, profile_row, ContactsListView, +}; + +use crate::cache::CreateConvoState; pub struct CreateConvoUi<'a> { ndb: &'a Ndb, @@ -10,6 +19,7 @@ pub struct CreateConvoUi<'a> { img_cache: &'a mut Images, contacts: &'a ContactState, i18n: &'a mut Localization, + state: &'a mut CreateConvoState, } pub struct CreateConvoResponse { @@ -23,6 +33,7 @@ impl<'a> CreateConvoUi<'a> { img_cache: &'a mut Images, contacts: &'a ContactState, i18n: &'a mut Localization, + state: &'a mut CreateConvoState, ) -> Self { Self { ndb, @@ -30,38 +41,231 @@ impl<'a> CreateConvoUi<'a> { img_cache, contacts, i18n, + state, } } pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<CreateConvoResponse> { - let ContactState::Received { contacts, .. } = self.contacts else { - // TODO render something about not having contacts - return None; + let contacts_set = match self.contacts { + ContactState::Received { contacts, .. } => Some(contacts), + _ => None, }; let txn = Transaction::new(self.ndb).expect("txn"); - ui.add(Label::new( - RichText::new(tr!( - self.i18n, - "Contacts", - "Heading shown when choosing a contact to start a new chat" - )) - .text_style(NotedeckTextStyle::Heading.text_style()), - )); - let resp = ContactsListView::new( - ContactsCollection::Set(contacts), - self.jobs, - self.ndb, - self.img_cache, - &txn, - ) - .ui(ui); - - resp.output.map(|a| match a { - notedeck_ui::ContactsListAction::Select(pubkey) => { - CreateConvoResponse { recipient: pubkey } + // Search input + ui.add_space(8.0); + search_input(&mut self.state.query, self.i18n, ui); + ui.add_space(12.0); + + let query = self.state.query.trim(); + + if query.is_empty() { + // Show contacts list when not searching + ui.add(Label::new( + RichText::new(tr!( + self.i18n, + "Contacts", + "Heading shown when choosing a contact to start a new chat" + )) + .text_style(NotedeckTextStyle::Heading.text_style()), + )); + + if let Some(contacts) = contacts_set { + let resp = ContactsListView::new( + ContactsCollection::Set(contacts), + self.jobs, + self.ndb, + self.img_cache, + &txn, + self.i18n, + ) + .ui(ui); + + resp.output.map(|a| match a { + notedeck_ui::ContactsListAction::Select(pubkey) => { + CreateConvoResponse { recipient: pubkey } + } + }) + } else { + // No contacts yet + ui.label(tr!( + self.i18n, + "No contacts yet", + "Shown when user has no contacts to display" + )); + None + } + } else { + // Show search results + ui.add(Label::new( + RichText::new(tr!( + self.i18n, + "Results", + "Heading shown above search results" + )) + .text_style(NotedeckTextStyle::Heading.text_style()), + )); + + let results = search_profiles(self.ndb, &txn, query, contacts_set); + + if results.is_empty() { + ui.add_space(20.0); + ui.label( + RichText::new(tr!( + self.i18n, + "No profiles found", + "Shown when profile search returns no results" + )) + .weak(), + ); + None + } else { + search_results_list( + ui, + &results, + self.ndb, + &txn, + self.img_cache, + self.jobs, + self.i18n, + ) + } + } + } +} + +/// Renders the search input field for profile search. +fn search_input(query: &mut String, i18n: &mut Localization, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + let search_container = egui::Frame { + inner_margin: egui::Margin::symmetric(8, 0), + outer_margin: egui::Margin::ZERO, + corner_radius: CornerRadius::same(18), + shadow: Default::default(), + fill: if ui.visuals().dark_mode { + Color32::from_rgb(30, 30, 30) + } else { + Color32::from_rgb(240, 240, 240) + }, + stroke: if ui.visuals().dark_mode { + Stroke::new(1.0, Color32::from_rgb(60, 60, 60)) + } else { + Stroke::new(1.0, Color32::from_rgb(200, 200, 200)) + }, + }; + + search_container.show(ui, |ui| { + ui.with_layout(egui::Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); + + let search_height = 34.0; + ui.add(search_icon(16.0, search_height)); + + ui.add_sized( + [ui.available_width(), search_height], + TextEdit::singleline(query) + .hint_text( + RichText::new(tr!( + i18n, + "Search profiles...", + "Placeholder for profile search input" + )) + .weak(), + ) + .margin(egui::vec2(0.0, 8.0)) + .frame(false), + ); + }); + }); + }); +} + +/// A profile search result. +struct SearchResult<'a> { + /// The public key bytes of the matched profile. + pk: &'a [u8; 32], + /// Whether this profile is in the user's contacts. + is_contact: bool, +} + +/// Searches for profiles matching `query` in nostrdb and the user's contacts. +/// Contacts are prioritized and appear first in results. Returns up to 20 matches. +fn search_profiles<'a>( + ndb: &Ndb, + txn: &'a Transaction, + query: &str, + contacts: Option<&'a HashSet<Pubkey>>, +) -> Vec<SearchResult<'a>> { + let mut results: Vec<SearchResult<'a>> = Vec::new(); + let mut seen: HashSet<&[u8; 32]> = HashSet::new(); + let query_lower = query.to_lowercase(); + + // First, add matching contacts (prioritized) + if let Some(contacts) = contacts { + for pk in contacts { + if let Ok(profile) = ndb.get_profile_by_pubkey(txn, pk.bytes()) { + let name = get_display_name(Some(&profile)).name(); + if name.to_lowercase().contains(&query_lower) { + results.push(SearchResult { + pk: pk.bytes(), + is_contact: true, + }); + seen.insert(pk.bytes()); + } + } + } + } + + // Then add nostrdb search results + if let Ok(pks) = ndb.search_profile(txn, query, 20) { + for pk_bytes in pks { + if !seen.contains(pk_bytes) { + let is_contact = contacts.map_or(false, |c| c.contains(pk_bytes)); + results.push(SearchResult { + pk: pk_bytes, + is_contact, + }); + seen.insert(pk_bytes); } - }) + } } + + results.truncate(20); + results +} + +/// Renders a scrollable list of search results. Returns `Some(CreateConvoResponse)` +/// if the user selects a profile. +fn search_results_list( + ui: &mut egui::Ui, + results: &[SearchResult<'_>], + ndb: &Ndb, + txn: &Transaction, + img_cache: &mut Images, + jobs: &MediaJobSender, + i18n: &mut Localization, +) -> Option<CreateConvoResponse> { + 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(CreateConvoResponse { + recipient: Pubkey::new(*result.pk), + }); + } + } + }); + + action } diff --git a/crates/notedeck_messages/src/ui/nav.rs b/crates/notedeck_messages/src/ui/nav.rs @@ -10,6 +10,7 @@ use notedeck_ui::{ header::{chevron, HorizontalHeader}, }; +pub use crate::cache::CreateConvoState; use crate::{ cache::{ConversationCache, ConversationStates}, nav::{MessagesAction, Route}, @@ -123,7 +124,15 @@ fn render_nav_body( .inner } Route::CreateConvo => 's: { - let Some(r) = CreateConvoUi::new(ndb, jobs, img_cache, contacts, i18n).ui(ui) else { + let Some(r) = CreateConvoUi::new( + ndb, + jobs, + img_cache, + contacts, + i18n, + &mut states.create_convo, + ) + .ui(ui) else { break 's None; }; diff --git a/crates/notedeck_ui/src/contacts_list.rs b/crates/notedeck_ui/src/contacts_list.rs @@ -3,17 +3,78 @@ use std::collections::HashSet; use crate::ProfilePic; use egui::{RichText, Sense}; use enostr::Pubkey; -use nostrdb::{Ndb, Transaction}; +use nostrdb::{Ndb, ProfileRecord, Transaction}; use notedeck::{ - name::get_display_name, profile::get_profile_url, DragResponse, Images, MediaJobSender, + name::get_display_name, profile::get_profile_url, tr, DragResponse, Images, Localization, + MediaJobSender, }; +/// Render a profile row with picture and name, optionally showing a contact badge. Returns true if clicked. +pub fn profile_row( + ui: &mut egui::Ui, + profile: Option<&ProfileRecord<'_>>, + is_contact: bool, + img_cache: &mut Images, + jobs: &MediaJobSender, + i18n: &mut Localization, +) -> bool { + let (rect, resp) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), Sense::click()); + + if !ui.clip_rect().intersects(rect) { + return false; + } + + let name_str = get_display_name(profile).name(); + let profile_url = get_profile_url(profile); + + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); + + if resp.hovered() { + ui.painter() + .rect_filled(rect, 0.0, ui.visuals().widgets.hovered.weak_bg_fill); + } + + let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + child_ui.horizontal(|ui| { + ui.add_space(16.0); + ui.add(&mut ProfilePic::new(img_cache, jobs, profile_url).size(48.0)); + ui.add_space(12.0); + ui.add( + egui::Label::new( + RichText::new(name_str) + .size(16.0) + .color(ui.visuals().text_color()), + ) + .selectable(false), + ); + if is_contact { + ui.add_space(8.0); + ui.add( + egui::Label::new( + RichText::new(tr!( + i18n, + "Contact", + "Badge indicating this profile is in contacts" + )) + .size(12.0) + .color(ui.visuals().weak_text_color()), + ) + .selectable(false), + ); + } + }); + + resp.clicked() +} + pub struct ContactsListView<'a, 'txn> { contacts: ContactsCollection<'a>, jobs: &'a MediaJobSender, ndb: &'a Ndb, img_cache: &'a mut Images, txn: &'txn Transaction, + i18n: &'a mut Localization, } #[derive(Clone)] @@ -58,6 +119,7 @@ impl<'a, 'txn> ContactsListView<'a, 'txn> { ndb: &'a Ndb, img_cache: &'a mut Images, txn: &'txn Transaction, + i18n: &'a mut Localization, ) -> Self { ContactsListView { contacts, @@ -65,6 +127,7 @@ impl<'a, 'txn> ContactsListView<'a, 'txn> { img_cache, txn, jobs, + i18n, } } @@ -72,51 +135,20 @@ impl<'a, 'txn> ContactsListView<'a, 'txn> { let mut action = None; egui::ScrollArea::vertical().show(ui, |ui| { - let clip_rect = ui.clip_rect(); - for contact_pubkey in self.contacts.iter() { - let (rect, resp) = - ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), Sense::click()); - - if !clip_rect.intersects(rect) { - continue; - } - let profile = self .ndb .get_profile_by_pubkey(self.txn, contact_pubkey.bytes()) .ok(); - let display_name = get_display_name(profile.as_ref()); - let name_str = display_name.display_name.unwrap_or("Anonymous"); - let profile_url = get_profile_url(profile.as_ref()); - - let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); - - if resp.hovered() { - ui.painter() - .rect_filled(rect, 0.0, ui.visuals().widgets.hovered.weak_bg_fill); - } - - let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect)); - child_ui.horizontal(|ui| { - ui.add_space(16.0); - - ui.add(&mut ProfilePic::new(self.img_cache, self.jobs, profile_url).size(48.0)); - - ui.add_space(12.0); - - ui.add( - egui::Label::new( - RichText::new(name_str) - .size(16.0) - .color(ui.visuals().text_color()), - ) - .selectable(false), - ); - }); - - if resp.clicked() { + if profile_row( + ui, + profile.as_ref(), + false, + self.img_cache, + self.jobs, + self.i18n, + ) { action = Some(ContactsListAction::Select(*contact_pubkey)); } } diff --git a/crates/notedeck_ui/src/lib.rs b/crates/notedeck_ui/src/lib.rs @@ -17,7 +17,7 @@ mod username; pub mod widgets; pub use anim::{rolling_number, AnimationHelper, PulseAlpha}; -pub use contacts_list::{ContactsListAction, ContactsListView}; +pub use contacts_list::{profile_row, ContactsListAction, ContactsListView}; pub use debug::debug_slider; pub use icons::{expanding_button, ICON_EXPANSION_MULTIPLE, ICON_WIDTH}; pub use mention::Mention;