commit c52a49f251a8d93396077c4fb895cc26bdf60006
parent 61f97fe15cddefebcea1c74b969a021c22aca070
Author: William Casarin <jb55@jb55.com>
Date: Thu, 5 Feb 2026 00:23:36 -0800
ui: consolidate profile search into shared notedeck_ui widgets
Extract shared UI components used by both create_convo.rs and search/mod.rs:
- Add search_input_frame(), search_input_box(), SEARCH_INPUT_HEIGHT to widgets.rs
- Add profile_row_widget() with ProfileRowOptions for configurable profile rows
(contact badge, X button, selection highlighting)
- Add search_profiles() function that prioritizes contacts and deduplicates results
- Update create_convo.rs and search/mod.rs to use shared widgets
This ensures both UIs have consistent styling and behavior, including:
- Contact badge shown for profiles in user's contact list
- Contacts sorted first in search results
- No duplicate results
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
5 files changed, 331 insertions(+), 312 deletions(-)
diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs
@@ -1,4 +1,4 @@
-use egui::{vec2, Align, Color32, CornerRadius, Key, RichText, Stroke, TextEdit};
+use egui::{vec2, Align, Key, RichText, TextEdit};
use enostr::{NoteId, Pubkey};
use state::TypingType;
@@ -7,17 +7,17 @@ use crate::{
ui::timeline::TimelineTabView,
};
use egui_winit::clipboard::Clipboard;
-use nostrdb::{Filter, Ndb, ProfileRecord, Transaction};
+use nostrdb::{Filter, Ndb, Transaction};
use notedeck::{
- fonts::get_font_size, name::get_display_name, profile::get_profile_url, tr, tr_plural,
- DragResponse, Images, Localization, MediaJobSender, NoteAction, NoteContext, NoteRef,
- NotedeckTextStyle,
+ fonts::get_font_size, tr, tr_plural, DragResponse, IsFollowing, Localization, NoteAction,
+ NoteContext, NoteRef, NotedeckTextStyle,
};
use notedeck_ui::{
context_menu::{input_context, PasteBehavior},
icons::search_icon,
- padding, NoteOptions, ProfilePic,
+ padding, profile_row_widget, search_input_frame, search_profiles, NoteOptions,
+ ProfileRowOptions, SEARCH_INPUT_HEIGHT,
};
use std::time::{Duration, Instant};
use tracing::{error, info, warn};
@@ -85,14 +85,21 @@ impl<'a, 'd> SearchView<'a, 'd> {
| SearchState::Navigating
| SearchState::Typing(TypingType::Mention(_)) => {
if !self.query.string.is_empty() && !self.query.string.starts_with('@') {
- self.query.user_results = self
- .note_context
- .ndb
- .search_profile(self.txn, &self.query.string, 10)
- .unwrap_or_default()
- .iter()
- .map(|&pk| pk.to_vec())
- .collect();
+ self.query.user_results = search_profiles(
+ self.note_context.ndb,
+ self.txn,
+ &self.query.string,
+ self.note_context
+ .accounts
+ .get_selected_account()
+ .data
+ .contacts
+ .get_state(),
+ 128,
+ )
+ .into_iter()
+ .map(|r| r.pk.to_vec())
+ .collect();
if let Some(action) = self.show_search_suggestions(ui, keyboard_resp) {
search_action = Some(action);
}
@@ -242,31 +249,45 @@ impl<'a, 'd> SearchView<'a, 'd> {
}
}
- for (i, pk_bytes) in self.query.user_results.iter().enumerate() {
- if let Ok(pk_array) = TryInto::<[u8; 32]>::try_into(pk_bytes.as_slice()) {
- let pubkey = Pubkey::new(pk_array);
- let profile = self
- .note_context
- .ndb
- .get_profile_by_pubkey(self.txn, &pk_array)
- .ok();
- let is_selected = self.query.selected_index == (i + 1) as i32;
-
- let resp = ui.add(recent_profile_item(
- profile.as_ref(),
- is_selected,
- ui.available_width(),
- self.note_context.img_cache,
- self.note_context.jobs,
- ));
+ let mut action = None;
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ for (i, pk_bytes) in self.query.user_results.iter().enumerate() {
+ if let Ok(pk_array) = TryInto::<[u8; 32]>::try_into(pk_bytes.as_slice()) {
+ let pubkey = Pubkey::new(pk_array);
+ let profile = self
+ .note_context
+ .ndb
+ .get_profile_by_pubkey(self.txn, &pk_array)
+ .ok();
+ let is_selected = self.query.selected_index == (i + 1) as i32;
+ let is_contact = self
+ .note_context
+ .accounts
+ .get_selected_account()
+ .data
+ .contacts
+ .is_following(&pk_array)
+ == IsFollowing::Yes;
+
+ let options = ProfileRowOptions::new()
+ .selected(is_selected)
+ .contact_badge(is_contact);
+ let resp = ui.add(profile_row_widget(
+ profile.as_ref(),
+ self.note_context.img_cache,
+ self.note_context.jobs,
+ self.note_context.i18n,
+ options,
+ ));
- if resp.clicked() {
- return Some(SearchAction::NavigateToProfile(pubkey));
+ if resp.clicked() {
+ action = Some(SearchAction::NavigateToProfile(pubkey));
+ }
}
}
- }
+ });
- None
+ action
}
fn show_recent_searches(
@@ -321,12 +342,24 @@ impl<'a, 'd> SearchView<'a, 'd> {
.ndb
.get_profile_by_pubkey(self.txn, pubkey.bytes())
.ok();
- let resp = ui.add(recent_profile_item(
+ let is_contact = self
+ .note_context
+ .accounts
+ .get_selected_account()
+ .data
+ .contacts
+ .is_following(pubkey.bytes())
+ == IsFollowing::Yes;
+ let options = ProfileRowOptions::new()
+ .selected(is_selected)
+ .x_button(true)
+ .contact_badge(is_contact);
+ let resp = ui.add(profile_row_widget(
profile.as_ref(),
- is_selected,
- ui.available_width(),
self.note_context.img_cache,
self.note_context.jobs,
+ self.note_context.i18n,
+ options,
));
if resp.clicked() || (is_selected && keyboard_resp.enter_pressed) {
@@ -502,40 +535,17 @@ fn search_box(
clipboard: &mut Clipboard,
) -> SearchResponse {
ui.horizontal(|ui| {
- // Container for search input and icon
- let search_container = egui::Frame {
- inner_margin: egui::Margin::symmetric(8, 0),
- outer_margin: egui::Margin::ZERO,
- corner_radius: CornerRadius::same(18), // More rounded corners
- 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
+ search_input_frame(ui.visuals().dark_mode)
.show(ui, |ui| {
- // Use layout to align items vertically centered
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;
- // Magnifying glass icon
- ui.add(search_icon(16.0, search_height));
+ ui.add(search_icon(16.0, SEARCH_INPUT_HEIGHT));
let before_len = input.len();
- // Search input field
- //let font_size = notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
let response = ui.add_sized(
- [ui.available_width(), search_height],
+ [ui.available_width(), SEARCH_INPUT_HEIGHT],
TextEdit::singleline(input)
.hint_text(
RichText::new(tr!(
@@ -545,8 +555,6 @@ fn search_box(
))
.weak(),
)
- //.desired_width(available_width - 32.0)
- //.font(egui::FontId::new(font_size, egui::FontFamily::Proportional))
.margin(vec2(0.0, 8.0))
.frame(false),
);
@@ -699,85 +707,6 @@ fn search_hashtag(
Some(qrs.into_iter().map(NoteRef::from_query_result).collect())
}
-fn recent_profile_item<'a>(
- profile: Option<&'a ProfileRecord<'_>>,
- is_selected: bool,
- width: f32,
- cache: &'a mut Images,
- jobs: &'a MediaJobSender,
-) -> impl egui::Widget + 'a {
- move |ui: &mut egui::Ui| -> egui::Response {
- let min_img_size = 48.0;
- let spacing = 8.0;
- let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
- let x_button_size = 32.0;
-
- let (rect, resp) =
- ui.allocate_exact_size(vec2(width, min_img_size + 8.0), egui::Sense::click());
-
- let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
-
- if is_selected {
- ui.painter()
- .rect_filled(rect, 4.0, ui.visuals().selection.bg_fill);
- }
-
- if resp.hovered() {
- ui.painter()
- .rect_filled(rect, 4.0, ui.visuals().widgets.hovered.bg_fill);
- }
-
- let pfp_rect =
- egui::Rect::from_min_size(rect.min + vec2(4.0, 4.0), vec2(min_img_size, min_img_size));
-
- ui.put(
- pfp_rect,
- &mut ProfilePic::new(cache, jobs, get_profile_url(profile)).size(min_img_size),
- );
-
- let name = get_display_name(profile).name();
- let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family());
- let painter = ui.painter();
- let text_galley = painter.layout(
- name.to_string(),
- name_font,
- ui.visuals().text_color(),
- width - min_img_size - spacing - x_button_size - 8.0,
- );
-
- let galley_pos = egui::Pos2::new(
- pfp_rect.right() + spacing,
- rect.center().y - (text_galley.rect.height() / 2.0),
- );
-
- painter.galley(galley_pos, text_galley, ui.visuals().text_color());
-
- let x_rect = egui::Rect::from_min_size(
- egui::Pos2::new(rect.right() - x_button_size, rect.top()),
- vec2(x_button_size, rect.height()),
- );
-
- let x_center = x_rect.center();
- let x_size = 12.0;
- painter.line_segment(
- [
- egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y - x_size / 2.0),
- egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y + x_size / 2.0),
- ],
- egui::Stroke::new(1.5, ui.visuals().text_color()),
- );
- painter.line_segment(
- [
- egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y - x_size / 2.0),
- egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y + x_size / 2.0),
- ],
- egui::Stroke::new(1.5, ui.visuals().text_color()),
- );
-
- resp
- }
-}
-
fn recent_search_item(
query: &str,
is_selected: bool,
diff --git a/crates/notedeck_messages/src/ui/create_convo.rs b/crates/notedeck_messages/src/ui/create_convo.rs
@@ -1,14 +1,10 @@
-use std::collections::HashSet;
-
-use egui::{Align, Color32, CornerRadius, Label, RichText, Stroke, TextEdit};
+use egui::{Label, RichText};
use enostr::Pubkey;
use nostrdb::{Ndb, Transaction};
-use notedeck::{
- name::get_display_name, tr, ContactState, Images, Localization, MediaJobSender,
- NotedeckTextStyle,
-};
+use notedeck::{tr, ContactState, Images, Localization, MediaJobSender, NotedeckTextStyle};
use notedeck_ui::{
- contacts_list::ContactsCollection, icons::search_icon, profile_row, ContactsListView,
+ contacts_list::ContactsCollection, profile_row, search_input_box, search_profiles,
+ ContactsListView, ProfileSearchResult,
};
use crate::cache::CreateConvoState;
@@ -46,16 +42,16 @@ impl<'a> CreateConvoUi<'a> {
}
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<CreateConvoResponse> {
- let contacts_set = match self.contacts {
- ContactState::Received { contacts, .. } => Some(contacts),
- _ => None,
- };
-
let txn = Transaction::new(self.ndb).expect("txn");
// Search input
ui.add_space(8.0);
- search_input(&mut self.state.query, self.i18n, ui);
+ let hint = tr!(
+ self.i18n,
+ "Search profiles...",
+ "Placeholder for profile search input"
+ );
+ ui.add(search_input_box(&mut self.state.query, &hint));
ui.add_space(12.0);
let query = self.state.query.trim();
@@ -71,7 +67,7 @@ impl<'a> CreateConvoUi<'a> {
.text_style(NotedeckTextStyle::Heading.text_style()),
));
- if let Some(contacts) = contacts_set {
+ if let ContactState::Received { contacts, .. } = self.contacts {
let resp = ContactsListView::new(
ContactsCollection::Set(contacts),
self.jobs,
@@ -107,7 +103,7 @@ impl<'a> CreateConvoUi<'a> {
.text_style(NotedeckTextStyle::Heading.text_style()),
));
- let results = search_profiles(self.ndb, &txn, query, contacts_set);
+ let results = search_profiles(self.ndb, &txn, query, self.contacts, 128);
if results.is_empty() {
ui.add_space(20.0);
@@ -135,111 +131,11 @@ impl<'a> CreateConvoUi<'a> {
}
}
-/// 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.is_some_and(|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<'_>],
+ results: &[ProfileSearchResult],
ndb: &Ndb,
txn: &Transaction,
img_cache: &mut Images,
@@ -250,7 +146,7 @@ fn search_results_list(
egui::ScrollArea::vertical().show(ui, |ui| {
for result in results {
- let profile = ndb.get_profile_by_pubkey(txn, result.pk).ok();
+ let profile = ndb.get_profile_by_pubkey(txn, &result.pk).ok();
if profile_row(
ui,
@@ -261,7 +157,7 @@ fn search_results_list(
i18n,
) {
action = Some(CreateConvoResponse {
- recipient: Pubkey::new(*result.pk),
+ recipient: Pubkey::new(result.pk),
});
}
}
diff --git a/crates/notedeck_ui/src/contacts_list.rs b/crates/notedeck_ui/src/contacts_list.rs
@@ -1,71 +1,153 @@
use std::collections::HashSet;
use crate::ProfilePic;
-use egui::{RichText, Sense};
+use egui::{RichText, Sense, Stroke};
use enostr::Pubkey;
use nostrdb::{Ndb, ProfileRecord, Transaction};
use notedeck::{
- name::get_display_name, profile::get_profile_url, tr, DragResponse, Images, Localization,
- MediaJobSender,
+ name::get_display_name, profile::get_profile_url, tr, ContactState, 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());
+/// Configuration options for profile row rendering.
+#[derive(Default)]
+pub struct ProfileRowOptions {
+ /// Show "Contact" badge next to the name
+ pub show_contact_badge: bool,
+ /// Show X button on the right (visual only - deletion handled by caller)
+ pub show_x_button: bool,
+ /// Highlight as selected (keyboard navigation)
+ pub is_selected: bool,
+}
- if !ui.clip_rect().intersects(rect) {
- return false;
+impl ProfileRowOptions {
+ pub fn new() -> Self {
+ Self::default()
}
- let name_str = get_display_name(profile).name();
- let profile_url = get_profile_url(profile);
+ pub fn contact_badge(mut self, show: bool) -> Self {
+ self.show_contact_badge = show;
+ self
+ }
- let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
+ pub fn x_button(mut self, show: bool) -> Self {
+ self.show_x_button = show;
+ self
+ }
- if resp.hovered() {
- ui.painter()
- .rect_filled(rect, 0.0, ui.visuals().widgets.hovered.weak_bg_fill);
+ pub fn selected(mut self, selected: bool) -> Self {
+ self.is_selected = selected;
+ self
}
+}
+
+/// Render a profile row with picture and name, with configurable options.
+/// Returns the response for handling clicks.
+pub fn profile_row_widget<'a>(
+ profile: Option<&'a ProfileRecord<'a>>,
+ img_cache: &'a mut Images,
+ jobs: &'a MediaJobSender,
+ i18n: &'a mut Localization,
+ options: ProfileRowOptions,
+) -> impl egui::Widget + 'a {
+ move |ui: &mut egui::Ui| -> egui::Response {
+ let (rect, resp) =
+ ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), Sense::click());
+
+ if !ui.clip_rect().intersects(rect) {
+ return resp;
+ }
+
+ let name_str = get_display_name(profile).name();
+ let profile_url = get_profile_url(profile);
+
+ let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
- 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 {
+ // Selection highlighting
+ if options.is_selected {
+ ui.painter()
+ .rect_filled(rect, 4.0, ui.visuals().selection.bg_fill);
+ }
+
+ // Hover highlighting
+ if resp.hovered() {
+ ui.painter()
+ .rect_filled(rect, 4.0, ui.visuals().widgets.hovered.bg_fill);
+ }
+
+ let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect));
+ child_ui.horizontal(|ui| {
+ ui.add_space(4.0);
+ ui.add(&mut ProfilePic::new(img_cache, jobs, profile_url).size(48.0));
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()),
+ RichText::new(name_str)
+ .size(16.0)
+ .color(ui.visuals().text_color()),
)
.selectable(false),
);
+ if options.show_contact_badge {
+ ui.add_space(8.0);
+ let badge_text = tr!(
+ i18n,
+ "Contact",
+ "Badge indicating this profile is in contacts"
+ );
+ ui.add(
+ egui::Label::new(
+ RichText::new(badge_text)
+ .size(12.0)
+ .color(ui.visuals().weak_text_color()),
+ )
+ .selectable(false),
+ );
+ }
+ });
+
+ // Draw X button on the right
+ if options.show_x_button {
+ let x_button_size = 32.0;
+ let x_size = 12.0;
+ let x_rect = egui::Rect::from_min_size(
+ egui::Pos2::new(rect.right() - x_button_size, rect.top()),
+ egui::vec2(x_button_size, rect.height()),
+ );
+ let x_center = x_rect.center();
+ let painter = ui.painter();
+ painter.line_segment(
+ [
+ egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y - x_size / 2.0),
+ egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y + x_size / 2.0),
+ ],
+ Stroke::new(1.5, ui.visuals().text_color()),
+ );
+ painter.line_segment(
+ [
+ egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y - x_size / 2.0),
+ egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y + x_size / 2.0),
+ ],
+ Stroke::new(1.5, ui.visuals().text_color()),
+ );
}
- });
- resp.clicked()
+ resp
+ }
+}
+
+/// 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 options = ProfileRowOptions::new().contact_badge(is_contact);
+ ui.add(profile_row_widget(profile, img_cache, jobs, i18n, options))
+ .clicked()
}
pub struct ContactsListView<'a, 'txn> {
@@ -157,3 +239,58 @@ impl<'a, 'txn> ContactsListView<'a, 'txn> {
DragResponse::output(action)
}
}
+
+/// A profile search result.
+pub struct ProfileSearchResult {
+ /// The public key bytes of the matched profile.
+ pub pk: [u8; 32],
+ /// Whether this profile is in the user's contacts.
+ pub is_contact: bool,
+}
+
+/// Searches for profiles matching `query`, prioritizing contacts first and deduplicating.
+/// Contacts that match appear first, followed by non-contact results.
+/// Returns up to `max_results` matches.
+pub fn search_profiles(
+ ndb: &Ndb,
+ txn: &Transaction,
+ query: &str,
+ contacts_state: &ContactState,
+ max_results: usize,
+) -> Vec<ProfileSearchResult> {
+ let contacts_set = match contacts_state {
+ ContactState::Received { contacts, .. } => Some(contacts),
+ _ => None,
+ };
+
+ // 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();
+ let mut seen: HashSet<&[u8; 32]> = HashSet::new();
+
+ if let Ok(pks) = ndb.search_profile(txn, query, max_results as u32) {
+ for pk_bytes in pks {
+ // Skip duplicates
+ if seen.contains(pk_bytes) {
+ continue;
+ }
+ seen.insert(pk_bytes);
+
+ let is_contact = contacts_set.is_some_and(|c| c.contains(pk_bytes));
+ let result = ProfileSearchResult {
+ pk: *pk_bytes,
+ is_contact,
+ };
+ if is_contact {
+ contact_results.push(result);
+ } else {
+ other_results.push(result);
+ }
+ }
+ }
+
+ // Combine: contacts first, then others
+ contact_results.extend(other_results);
+ contact_results.truncate(max_results);
+ contact_results
+}
diff --git a/crates/notedeck_ui/src/lib.rs b/crates/notedeck_ui/src/lib.rs
@@ -17,14 +17,20 @@ mod username;
pub mod widgets;
pub use anim::{rolling_number, AnimationHelper, PulseAlpha};
-pub use contacts_list::{profile_row, ContactsListAction, ContactsListView};
+pub use contacts_list::{
+ 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};
pub use mention::Mention;
pub use note::{NoteContents, NoteOptions, NoteView};
pub use profile::{ProfilePic, ProfilePreview};
pub use username::Username;
-pub use widgets::{side_panel_active_bg, side_panel_icon_tint};
+pub use widgets::{
+ search_input_box, search_input_frame, side_panel_active_bg, side_panel_icon_tint,
+ SEARCH_INPUT_HEIGHT,
+};
use egui::{Label, Margin, Pos2, RichText};
diff --git a/crates/notedeck_ui/src/widgets.rs b/crates/notedeck_ui/src/widgets.rs
@@ -1,5 +1,6 @@
use crate::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE};
-use egui::{emath::GuiRounding, Pos2, Stroke};
+use crate::icons::search_icon;
+use egui::{emath::GuiRounding, Align, Color32, CornerRadius, Pos2, RichText, Stroke, TextEdit};
use notedeck::NotedeckTextStyle;
pub fn x_button(rect: egui::Rect) -> impl egui::Widget {
@@ -95,3 +96,53 @@ pub fn side_panel_icon_tint(ui: &egui::Ui) -> egui::Color32 {
egui::Color32::BLACK
}
}
+
+/// Returns a styled Frame for search input boxes with rounded corners.
+pub fn search_input_frame(dark_mode: bool) -> egui::Frame {
+ egui::Frame {
+ inner_margin: egui::Margin::symmetric(8, 0),
+ outer_margin: egui::Margin::ZERO,
+ corner_radius: CornerRadius::same(18),
+ shadow: Default::default(),
+ fill: if dark_mode {
+ Color32::from_rgb(30, 30, 30)
+ } else {
+ Color32::from_rgb(240, 240, 240)
+ },
+ stroke: if dark_mode {
+ Stroke::new(1.0, Color32::from_rgb(60, 60, 60))
+ } else {
+ Stroke::new(1.0, Color32::from_rgb(200, 200, 200))
+ },
+ }
+}
+
+/// The standard height for search input boxes.
+pub const SEARCH_INPUT_HEIGHT: f32 = 34.0;
+
+/// A styled search input box with rounded corners and search icon.
+pub fn search_input_box<'a>(query: &'a mut String, hint_text: &'a str) -> impl egui::Widget + 'a {
+ move |ui: &mut egui::Ui| -> egui::Response {
+ ui.horizontal(|ui| {
+ search_input_frame(ui.visuals().dark_mode)
+ .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);
+
+ ui.add(search_icon(16.0, SEARCH_INPUT_HEIGHT));
+
+ ui.add_sized(
+ [ui.available_width(), SEARCH_INPUT_HEIGHT],
+ TextEdit::singleline(query)
+ .hint_text(RichText::new(hint_text).weak())
+ .margin(egui::vec2(0.0, 8.0))
+ .frame(false),
+ )
+ })
+ .inner
+ })
+ .inner
+ })
+ .response
+ }
+}