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