commit 5c9237e0708c9154a7e854cf41deccf6bb73deb6
parent fbc5d679aec8d6287f2a077b1bda38d1e68a0d2e
Author: William Casarin <jb55@jb55.com>
Date: Fri, 14 Nov 2025 17:38:32 -0800
Merge fix profile searches by martti #1193
Martti Malmi (7):
placeholder: Search posts, @users, #hashtags...
search: user results by default, search posts btn
recent searches, autofocus on back / fwd nav
badge on recent searched profiles
fix build errors
remove comments and fix warnings
fix profiles not showing up in search
Diffstat:
3 files changed, 582 insertions(+), 93 deletions(-)
diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs
@@ -8,20 +8,23 @@ use crate::{
ui::timeline::TimelineTabView,
};
use egui_winit::clipboard::Clipboard;
-use nostrdb::{Filter, Ndb, Transaction};
-use notedeck::{tr, tr_plural, JobsCache, Localization, NoteAction, NoteContext, NoteRef};
+use nostrdb::{Filter, Ndb, ProfileRecord, Transaction};
+use notedeck::{
+ fonts::get_font_size, name::get_display_name, profile::get_profile_url, tr, tr_plural, Images,
+ JobsCache, Localization, NoteAction, NoteContext, NoteRef, NotedeckTextStyle,
+};
use notedeck_ui::{
context_menu::{input_context, PasteBehavior},
icons::search_icon,
- padding, NoteOptions,
+ padding, NoteOptions, ProfilePic,
};
use std::time::{Duration, Instant};
use tracing::{error, info, warn};
mod state;
-pub use state::{FocusState, SearchQueryState, SearchState};
+pub use state::{FocusState, RecentSearchItem, SearchQueryState, SearchState};
use super::mentions_picker::{MentionPickerResponse, MentionPickerView};
@@ -51,10 +54,15 @@ impl<'a, 'd> SearchView<'a, 'd> {
}
pub fn show(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> {
- padding(8.0, ui, |ui| self.show_impl(ui)).inner
+ padding(8.0, ui, |ui| self.show_impl(ui))
+ .inner
+ .map_output(|action| match action {
+ SearchViewAction::NoteAction(note_action) => note_action,
+ SearchViewAction::NavigateToProfile(pubkey) => NoteAction::Profile(pubkey),
+ })
}
- pub fn show_impl(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> {
+ fn show_impl(&mut self, ui: &mut egui::Ui) -> BodyResponse<SearchViewAction> {
ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0);
let search_resp = search_box(
@@ -67,53 +75,39 @@ impl<'a, 'd> SearchView<'a, 'd> {
search_resp.process(self.query);
+ let keyboard_resp = handle_keyboard_navigation(
+ ui,
+ &mut self.query.selected_index,
+ &self.query.user_results,
+ );
+
let mut search_action = None;
let mut body_resp = BodyResponse::none();
match &self.query.state {
- SearchState::New | SearchState::Navigating => {}
- SearchState::Typing(TypingType::Mention(mention_name)) => 's: {
- let Ok(results) = self
- .note_context
- .ndb
- .search_profile(self.txn, mention_name, 10)
- else {
- break 's;
- };
-
- let search_res = MentionPickerView::new(
- self.note_context.img_cache,
- self.note_context.ndb,
- self.txn,
- &results,
- )
- .show_in_rect(ui.available_rect_before_wrap(), ui);
-
- let Some(res) = search_res.output else {
- break 's;
- };
-
- search_action = match res {
- MentionPickerResponse::SelectResult(Some(index)) => {
- let Some(pk_bytes) = results.get(index) else {
- break 's;
- };
-
- let username = self
- .note_context
- .ndb
- .get_profile_by_pubkey(self.txn, pk_bytes)
- .ok()
- .and_then(|p| p.record().profile().and_then(|p| p.name()))
- .unwrap_or(&self.query.string);
-
- Some(SearchAction::NewSearch {
- search_type: SearchType::Profile(Pubkey::new(**pk_bytes)),
- new_search_text: format!("@{username}"),
- })
+ SearchState::New
+ | 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();
+ if let Some(action) = self.show_search_suggestions(ui, keyboard_resp) {
+ search_action = Some(action);
+ }
+ } else if self.query.string.starts_with('@') {
+ self.handle_mention_search(ui, &mut search_action);
+ } else {
+ self.query.user_results.clear();
+ self.query.selected_index = -1;
+ if let Some(action) = self.show_recent_searches(ui, keyboard_resp) {
+ search_action = Some(action);
}
- MentionPickerResponse::DeleteMention => Some(SearchAction::CloseMention),
- MentionPickerResponse::SelectResult(None) => break 's,
- };
+ }
}
SearchState::PerformSearch(search_type) => {
execute_search(
@@ -125,7 +119,10 @@ impl<'a, 'd> SearchView<'a, 'd> {
&mut self.query.notes,
);
search_action = Some(SearchAction::Searched);
- body_resp.insert(self.show_search_results(ui));
+ body_resp.insert(
+ self.show_search_results(ui)
+ .map_output(SearchViewAction::NoteAction),
+ );
}
SearchState::Searched => {
ui.label(tr_plural!(
@@ -136,25 +133,222 @@ impl<'a, 'd> SearchView<'a, 'd> {
self.query.notes.units.len(), // count
query = &self.query.string
));
- body_resp.insert(self.show_search_results(ui));
+ body_resp.insert(
+ self.show_search_results(ui)
+ .map_output(SearchViewAction::NoteAction),
+ );
}
- SearchState::Typing(TypingType::AutoSearch) => {
- ui.label(tr!(
- self.note_context.i18n,
- "Searching for '{query}'",
- "Search in progress message",
- query = &self.query.string
- ));
+ };
- body_resp.insert(self.show_search_results(ui));
+ if let Some(action) = search_action {
+ if let Some(view_action) = action.process(self.query) {
+ body_resp.output = Some(view_action);
}
+ }
+
+ body_resp
+ }
+
+ fn handle_mention_search(
+ &mut self,
+ ui: &mut egui::Ui,
+ search_action: &mut Option<SearchAction>,
+ ) {
+ let mention_name = if let Some(mention_text) = self.query.string.get(1..) {
+ mention_text
+ } else {
+ return;
};
- if let Some(resp) = search_action {
- resp.process(self.query);
+ 's: {
+ let Ok(results) = self
+ .note_context
+ .ndb
+ .search_profile(self.txn, mention_name, 10)
+ else {
+ break 's;
+ };
+
+ let search_res = MentionPickerView::new(
+ self.note_context.img_cache,
+ self.note_context.ndb,
+ self.txn,
+ &results,
+ )
+ .show_in_rect(ui.available_rect_before_wrap(), ui);
+
+ let Some(res) = search_res.output else {
+ break 's;
+ };
+
+ *search_action = match res {
+ MentionPickerResponse::SelectResult(Some(index)) => {
+ let Some(pk_bytes) = results.get(index) else {
+ break 's;
+ };
+
+ let username = self
+ .note_context
+ .ndb
+ .get_profile_by_pubkey(self.txn, pk_bytes)
+ .ok()
+ .and_then(|p| p.record().profile().and_then(|p| p.name()))
+ .unwrap_or(&self.query.string);
+
+ Some(SearchAction::NewSearch {
+ search_type: SearchType::Profile(Pubkey::new(**pk_bytes)),
+ new_search_text: format!("@{username}"),
+ })
+ }
+ MentionPickerResponse::DeleteMention => Some(SearchAction::CloseMention),
+ MentionPickerResponse::SelectResult(None) => break 's,
+ };
}
+ }
- body_resp
+ fn show_search_suggestions(
+ &mut self,
+ ui: &mut egui::Ui,
+ keyboard_resp: KeyboardResponse,
+ ) -> Option<SearchAction> {
+ ui.add_space(8.0);
+
+ let is_selected = self.query.selected_index == 0;
+ let search_posts_clicked = ui
+ .add(search_posts_button(
+ &self.query.string,
+ is_selected,
+ ui.available_width(),
+ ))
+ .clicked()
+ || (is_selected && keyboard_resp.enter_pressed);
+
+ if search_posts_clicked {
+ let search_type = SearchType::get_type(&self.query.string);
+ // If it's a profile (npub), navigate to the profile instead of searching posts
+ if let SearchType::Profile(pubkey) = search_type {
+ return Some(SearchAction::NavigateToProfile(pubkey));
+ }
+ return Some(SearchAction::NewSearch {
+ search_type,
+ new_search_text: self.query.string.clone(),
+ });
+ }
+
+ if keyboard_resp.enter_pressed && self.query.selected_index > 0 {
+ let user_idx = (self.query.selected_index - 1) as usize;
+ if let Some(pk_bytes) = self.query.user_results.get(user_idx) {
+ if let Ok(pk_array) = TryInto::<[u8; 32]>::try_into(pk_bytes.as_slice()) {
+ return Some(SearchAction::NavigateToProfile(Pubkey::new(pk_array)));
+ }
+ }
+ }
+
+ 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(),
+ &pubkey,
+ &self.query.string,
+ is_selected,
+ ui.available_width(),
+ self.note_context.img_cache,
+ self.note_context.accounts,
+ ));
+
+ if resp.clicked() {
+ return Some(SearchAction::NavigateToProfile(pubkey));
+ }
+ }
+ }
+
+ None
+ }
+
+ fn show_recent_searches(
+ &mut self,
+ ui: &mut egui::Ui,
+ keyboard_resp: KeyboardResponse,
+ ) -> Option<SearchAction> {
+ if self.query.recent_searches.is_empty() {
+ return None;
+ }
+
+ ui.add_space(8.0);
+ ui.horizontal(|ui| {
+ ui.label("Recent");
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if ui.button(RichText::new("Clear all").size(14.0)).clicked() {
+ self.query.clear_recent_searches();
+ }
+ });
+ });
+ ui.add_space(4.0);
+
+ let recent_searches = self.query.recent_searches.clone();
+ for (i, search_item) in recent_searches.iter().enumerate() {
+ let is_selected = self.query.selected_index == i as i32;
+
+ match search_item {
+ RecentSearchItem::Query(query) => {
+ let resp = ui.add(recent_search_item(
+ query,
+ is_selected,
+ ui.available_width(),
+ false,
+ ));
+
+ if resp.clicked() || (is_selected && keyboard_resp.enter_pressed) {
+ return Some(SearchAction::NewSearch {
+ search_type: SearchType::get_type(query),
+ new_search_text: query.clone(),
+ });
+ }
+
+ if resp.secondary_clicked()
+ || (is_selected && ui.input(|i| i.key_pressed(Key::Delete)))
+ {
+ self.query.remove_recent_search(i);
+ }
+ }
+ RecentSearchItem::Profile { pubkey, query } => {
+ let profile = self
+ .note_context
+ .ndb
+ .get_profile_by_pubkey(self.txn, pubkey.bytes())
+ .ok();
+ let resp = ui.add(recent_profile_item(
+ profile.as_ref(),
+ pubkey,
+ query,
+ is_selected,
+ ui.available_width(),
+ self.note_context.img_cache,
+ self.note_context.accounts,
+ ));
+
+ if resp.clicked() || (is_selected && keyboard_resp.enter_pressed) {
+ return Some(SearchAction::NavigateToProfile(*pubkey));
+ }
+
+ if resp.secondary_clicked()
+ || (is_selected && ui.input(|i| i.key_pressed(Key::Delete)))
+ {
+ self.query.remove_recent_search(i);
+ }
+ }
+ }
+ }
+
+ None
}
fn show_search_results(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> {
@@ -202,17 +396,23 @@ fn execute_search(
ctx.request_repaint();
}
+enum SearchViewAction {
+ NoteAction(NoteAction),
+ NavigateToProfile(Pubkey),
+}
+
enum SearchAction {
NewSearch {
search_type: SearchType,
new_search_text: String,
},
+ NavigateToProfile(Pubkey),
Searched,
CloseMention,
}
impl SearchAction {
- fn process(self, state: &mut SearchQueryState) {
+ fn process(self, state: &mut SearchQueryState) -> Option<SearchViewAction> {
match self {
SearchAction::NewSearch {
search_type,
@@ -220,9 +420,27 @@ impl SearchAction {
} => {
state.state = SearchState::PerformSearch(search_type);
state.string = new_search_text;
+ state.selected_index = -1;
+ None
+ }
+ SearchAction::NavigateToProfile(pubkey) => {
+ state.add_recent_profile(pubkey, state.string.clone());
+ state.string.clear();
+ state.selected_index = -1;
+ Some(SearchViewAction::NavigateToProfile(pubkey))
+ }
+ SearchAction::CloseMention => {
+ state.state = SearchState::New;
+ state.selected_index = -1;
+ None
+ }
+ SearchAction::Searched => {
+ state.state = SearchState::Searched;
+ state.selected_index = -1;
+ state.user_results.clear();
+ state.add_recent_query(state.string.clone());
+ None
}
- SearchAction::CloseMention => state.state = SearchState::New,
- SearchAction::Searched => state.state = SearchState::Searched,
}
}
}
@@ -236,29 +454,52 @@ impl SearchResponse {
fn process(self, state: &mut SearchQueryState) {
if self.requested_focus {
state.focus_state = FocusState::RequestedFocus;
+ } else if state.focus_state == FocusState::RequestedFocus && !self.input_changed {
+ state.focus_state = FocusState::Navigating;
}
- if state.string.chars().nth(0) != Some('@') {
- if self.input_changed {
- state.state = SearchState::Typing(TypingType::AutoSearch);
- state.debouncer.bounce();
+ if self.input_changed {
+ if state.string.starts_with('@') {
+ state.selected_index = -1;
+ if let Some(mention_text) = state.string.get(1..) {
+ state.state = SearchState::Typing(TypingType::Mention(mention_text.to_owned()));
+ }
+ } else if state.state == SearchState::Searched {
+ state.state = SearchState::New;
+ state.selected_index = 0;
+ } else if !state.string.is_empty() {
+ state.selected_index = 0;
+ } else {
+ state.selected_index = -1;
}
+ }
+ }
+}
- if state.state == SearchState::Typing(TypingType::AutoSearch)
- && state.debouncer.should_act()
- {
- state.state = SearchState::PerformSearch(SearchType::get_type(&state.string));
- }
+struct KeyboardResponse {
+ enter_pressed: bool,
+}
- return;
- }
+fn handle_keyboard_navigation(
+ ui: &mut egui::Ui,
+ selected_index: &mut i32,
+ user_results: &[Vec<u8>],
+) -> KeyboardResponse {
+ let max_index = if user_results.is_empty() {
+ -1
+ } else {
+ user_results.len() as i32
+ };
- if self.input_changed {
- if let Some(mention_text) = state.string.get(1..) {
- state.state = SearchState::Typing(TypingType::Mention(mention_text.to_owned()));
- }
- }
+ if ui.input(|i| i.key_pressed(Key::ArrowDown)) {
+ *selected_index = (*selected_index + 1).min(max_index);
+ } else if ui.input(|i| i.key_pressed(Key::ArrowUp)) {
+ *selected_index = (*selected_index - 1).max(-1);
}
+
+ let enter_pressed = ui.input(|i| i.key_pressed(Key::Enter));
+
+ KeyboardResponse { enter_pressed }
}
fn search_box(
@@ -307,8 +548,8 @@ fn search_box(
.hint_text(
RichText::new(tr!(
i18n,
- "Search notes...",
- "Placeholder for search notes input field"
+ "Search",
+ "Placeholder for search input field"
))
.weak(),
)
@@ -465,3 +706,201 @@ fn search_hashtag(
let qrs = ndb.query(txn, &[filter], max_results as i32).ok()?;
Some(qrs.into_iter().map(NoteRef::from_query_result).collect())
}
+
+fn recent_profile_item<'a>(
+ profile: Option<&'a ProfileRecord<'_>>,
+ _pubkey: &'a Pubkey,
+ _query: &'a str,
+ is_selected: bool,
+ width: f32,
+ cache: &'a mut Images,
+ _accounts: &'a notedeck::Accounts,
+) -> 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, 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,
+ width: f32,
+ _is_profile: bool,
+) -> impl egui::Widget + '_ {
+ 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());
+
+ 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 icon_rect =
+ egui::Rect::from_min_size(rect.min + vec2(4.0, 4.0), vec2(min_img_size, min_img_size));
+
+ ui.put(icon_rect, search_icon(min_img_size / 2.0, min_img_size));
+
+ let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family());
+ let painter = ui.painter();
+ let text_galley = painter.layout(
+ query.to_string(),
+ name_font,
+ ui.visuals().text_color(),
+ width - min_img_size - spacing - x_button_size - 8.0,
+ );
+
+ let galley_pos = egui::Pos2::new(
+ icon_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 search_posts_button(query: &str, is_selected: bool, width: f32) -> impl egui::Widget + '_ {
+ 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 (rect, resp) =
+ ui.allocate_exact_size(vec2(width, min_img_size + 8.0), egui::Sense::click());
+
+ 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 icon_rect =
+ egui::Rect::from_min_size(rect.min + vec2(4.0, 4.0), vec2(min_img_size, min_img_size));
+
+ ui.put(icon_rect, search_icon(min_img_size / 2.0, min_img_size));
+
+ let text = format!("Search posts for \"{}\"", query);
+ let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family());
+ let painter = ui.painter();
+ let text_galley = painter.layout(
+ text,
+ name_font,
+ ui.visuals().text_color(),
+ width - min_img_size - spacing - 8.0,
+ );
+
+ let galley_pos = egui::Pos2::new(
+ icon_rect.right() + spacing,
+ rect.center().y - (text_galley.rect.height() / 2.0),
+ );
+
+ painter.galley(galley_pos, text_galley, ui.visuals().text_color());
+
+ resp
+ }
+}
diff --git a/crates/notedeck_columns/src/ui/search/state.rs b/crates/notedeck_columns/src/ui/search/state.rs
@@ -1,6 +1,5 @@
use crate::timeline::TimelineTab;
-use notedeck::debouncer::Debouncer;
-use std::time::Duration;
+use enostr::Pubkey;
use super::SearchType;
@@ -16,7 +15,12 @@ pub enum SearchState {
#[derive(Debug, Eq, PartialEq)]
pub enum TypingType {
Mention(String),
- AutoSearch,
+}
+
+#[derive(Debug, Clone)]
+pub enum RecentSearchItem {
+ Query(String),
+ Profile { pubkey: Pubkey, query: String },
}
#[derive(Debug, Eq, PartialEq, Clone)]
@@ -37,20 +41,24 @@ pub struct SearchQueryState {
/// This holds our search query while we're updating it
pub string: String,
- /// When the debouncer timer elapses, we execute the search and mark
- /// our state as searchd. This will make sure we don't try to search
- /// again next frames
+ /// Current search state
pub state: SearchState,
/// A bit of context to know if we're navigating to the view. We
/// can use this to know when to request focus on the textedit
pub focus_state: FocusState,
- /// When was the input updated? We use this to debounce searches
- pub debouncer: Debouncer,
-
/// The search results
pub notes: TimelineTab,
+
+ /// Currently selected item index in search results (-1 = none, 0 = "search posts", 1+ = users)
+ pub selected_index: i32,
+
+ /// Cached user search results for the current query
+ pub user_results: Vec<Vec<u8>>,
+
+ /// Recent search history (most recent first, max 10)
+ pub recent_searches: Vec<RecentSearchItem>,
}
impl Default for SearchQueryState {
@@ -66,7 +74,47 @@ impl SearchQueryState {
state: SearchState::New,
notes: TimelineTab::default(),
focus_state: FocusState::Navigating,
- debouncer: Debouncer::new(Duration::from_millis(200)),
+ selected_index: -1,
+ user_results: Vec::new(),
+ recent_searches: Vec::new(),
}
}
+
+ pub fn add_recent_query(&mut self, query: String) {
+ if query.is_empty() {
+ return;
+ }
+
+ let item = RecentSearchItem::Query(query.clone());
+ self.recent_searches
+ .retain(|s| !matches!(s, RecentSearchItem::Query(q) if q == &query));
+ self.recent_searches.insert(0, item);
+ self.recent_searches.truncate(10);
+ }
+
+ pub fn add_recent_profile(&mut self, pubkey: Pubkey, query: String) {
+ if query.is_empty() {
+ return;
+ }
+
+ let item = RecentSearchItem::Profile {
+ pubkey,
+ query: query.clone(),
+ };
+ self.recent_searches.retain(
+ |s| !matches!(s, RecentSearchItem::Profile { pubkey: pk, .. } if pk == &pubkey),
+ );
+ self.recent_searches.insert(0, item);
+ self.recent_searches.truncate(10);
+ }
+
+ pub fn remove_recent_search(&mut self, index: usize) {
+ if index < self.recent_searches.len() {
+ self.recent_searches.remove(index);
+ }
+ }
+
+ pub fn clear_recent_searches(&mut self) {
+ self.recent_searches.clear();
+ }
}
diff --git a/crates/notedeck_columns/src/ui/settings.rs b/crates/notedeck_columns/src/ui/settings.rs
@@ -501,7 +501,9 @@ impl<'a> SettingsView<'a> {
self.settings.animate_nav_transitions,
));
}
+ });
+ ui.horizontal_wrapped(|ui| {
ui.label(richtext_small(tr!(
self.note_context.i18n,
"Max hashtags per note:",