Fixes: https://github.com/damus-io/notedeck/issues/942 Signed-off-by: William Casarin <jb55@jb55.com>
440 lines
13 KiB
Rust
440 lines
13 KiB
Rust
use egui::{vec2, Align, Color32, CornerRadius, RichText, Stroke, TextEdit};
|
|
use enostr::{KeypairUnowned, NoteId, Pubkey};
|
|
use state::TypingType;
|
|
|
|
use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView};
|
|
use egui_winit::clipboard::Clipboard;
|
|
use nostrdb::{Filter, Ndb, Transaction};
|
|
use notedeck::{MuteFun, NoteAction, NoteContext, NoteRef};
|
|
use notedeck_ui::{
|
|
context_menu::{input_context, PasteBehavior},
|
|
icons::search_icon,
|
|
jobs::JobsCache,
|
|
padding, NoteOptions,
|
|
};
|
|
use std::time::{Duration, Instant};
|
|
use tracing::{error, info, warn};
|
|
|
|
mod state;
|
|
|
|
pub use state::{FocusState, SearchQueryState, SearchState};
|
|
|
|
use super::search_results::{SearchResultsResponse, SearchResultsView};
|
|
|
|
pub struct SearchView<'a, 'd> {
|
|
query: &'a mut SearchQueryState,
|
|
note_options: NoteOptions,
|
|
txn: &'a Transaction,
|
|
is_muted: &'a MuteFun,
|
|
note_context: &'a mut NoteContext<'d>,
|
|
cur_acc: &'a KeypairUnowned<'a>,
|
|
jobs: &'a mut JobsCache,
|
|
}
|
|
|
|
impl<'a, 'd> SearchView<'a, 'd> {
|
|
pub fn new(
|
|
txn: &'a Transaction,
|
|
is_muted: &'a MuteFun,
|
|
note_options: NoteOptions,
|
|
query: &'a mut SearchQueryState,
|
|
note_context: &'a mut NoteContext<'d>,
|
|
cur_acc: &'a KeypairUnowned<'a>,
|
|
jobs: &'a mut JobsCache,
|
|
) -> Self {
|
|
Self {
|
|
txn,
|
|
is_muted,
|
|
query,
|
|
note_options,
|
|
note_context,
|
|
cur_acc,
|
|
jobs,
|
|
}
|
|
}
|
|
|
|
pub fn show(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> {
|
|
padding(8.0, ui, |ui| self.show_impl(ui)).inner
|
|
}
|
|
|
|
pub fn show_impl(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> {
|
|
ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0);
|
|
|
|
let search_resp = search_box(
|
|
&mut self.query.string,
|
|
self.query.focus_state.clone(),
|
|
ui,
|
|
self.note_context.clipboard,
|
|
);
|
|
|
|
search_resp.process(self.query);
|
|
|
|
let mut search_action = None;
|
|
let mut note_action = 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 = SearchResultsView::new(
|
|
self.note_context.img_cache,
|
|
self.note_context.ndb,
|
|
self.txn,
|
|
&results,
|
|
)
|
|
.show_in_rect(ui.available_rect_before_wrap(), ui);
|
|
|
|
search_action = match search_res {
|
|
SearchResultsResponse::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}"),
|
|
})
|
|
}
|
|
SearchResultsResponse::DeleteMention => Some(SearchAction::CloseMention),
|
|
SearchResultsResponse::SelectResult(None) => break 's,
|
|
};
|
|
}
|
|
SearchState::PerformSearch(search_type) => {
|
|
execute_search(
|
|
ui.ctx(),
|
|
search_type,
|
|
&self.query.string,
|
|
self.note_context.ndb,
|
|
self.txn,
|
|
&mut self.query.notes,
|
|
);
|
|
search_action = Some(SearchAction::Searched);
|
|
note_action = self.show_search_results(ui);
|
|
}
|
|
SearchState::Searched => {
|
|
ui.label(format!(
|
|
"Got {} results for '{}'",
|
|
self.query.notes.notes.len(),
|
|
&self.query.string
|
|
));
|
|
note_action = self.show_search_results(ui);
|
|
}
|
|
SearchState::Typing(TypingType::AutoSearch) => {
|
|
ui.label(format!("Searching for '{}'", &self.query.string));
|
|
|
|
note_action = self.show_search_results(ui);
|
|
}
|
|
};
|
|
|
|
if let Some(resp) = search_action {
|
|
resp.process(self.query);
|
|
}
|
|
|
|
note_action
|
|
}
|
|
|
|
fn show_search_results(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> {
|
|
egui::ScrollArea::vertical()
|
|
.show(ui, |ui| {
|
|
let reversed = false;
|
|
TimelineTabView::new(
|
|
&self.query.notes,
|
|
reversed,
|
|
self.note_options,
|
|
self.txn,
|
|
self.is_muted,
|
|
self.note_context,
|
|
self.cur_acc,
|
|
self.jobs,
|
|
)
|
|
.show(ui)
|
|
})
|
|
.inner
|
|
}
|
|
}
|
|
|
|
fn execute_search(
|
|
ctx: &egui::Context,
|
|
search_type: &SearchType,
|
|
raw_input: &String,
|
|
ndb: &Ndb,
|
|
txn: &Transaction,
|
|
tab: &mut TimelineTab,
|
|
) {
|
|
if raw_input.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let max_results = 500;
|
|
|
|
let Some(note_refs) = search_type.search(raw_input, ndb, txn, max_results) else {
|
|
return;
|
|
};
|
|
|
|
tab.notes = note_refs;
|
|
tab.list.borrow_mut().reset();
|
|
ctx.request_repaint();
|
|
}
|
|
|
|
enum SearchAction {
|
|
NewSearch {
|
|
search_type: SearchType,
|
|
new_search_text: String,
|
|
},
|
|
Searched,
|
|
CloseMention,
|
|
}
|
|
|
|
impl SearchAction {
|
|
fn process(self, state: &mut SearchQueryState) {
|
|
match self {
|
|
SearchAction::NewSearch {
|
|
search_type,
|
|
new_search_text,
|
|
} => {
|
|
state.state = SearchState::PerformSearch(search_type);
|
|
state.string = new_search_text;
|
|
}
|
|
SearchAction::CloseMention => state.state = SearchState::New,
|
|
SearchAction::Searched => state.state = SearchState::Searched,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SearchResponse {
|
|
requested_focus: bool,
|
|
input_changed: bool,
|
|
}
|
|
|
|
impl SearchResponse {
|
|
fn process(self, state: &mut SearchQueryState) {
|
|
if self.requested_focus {
|
|
state.focus_state = FocusState::RequestedFocus;
|
|
}
|
|
|
|
if state.string.chars().nth(0) != Some('@') {
|
|
if self.input_changed {
|
|
state.state = SearchState::Typing(TypingType::AutoSearch);
|
|
state.debouncer.bounce();
|
|
}
|
|
|
|
if state.state == SearchState::Typing(TypingType::AutoSearch)
|
|
&& state.debouncer.should_act()
|
|
{
|
|
state.state = SearchState::PerformSearch(SearchType::get_type(&state.string));
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if self.input_changed {
|
|
if let Some(mention_text) = state.string.get(1..) {
|
|
state.state = SearchState::Typing(TypingType::Mention(mention_text.to_owned()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn search_box(
|
|
input: &mut String,
|
|
focus_state: FocusState,
|
|
ui: &mut egui::Ui,
|
|
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
|
|
.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));
|
|
|
|
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],
|
|
TextEdit::singleline(input)
|
|
.hint_text(RichText::new("Search notes...").weak())
|
|
//.desired_width(available_width - 32.0)
|
|
//.font(egui::FontId::new(font_size, egui::FontFamily::Proportional))
|
|
.margin(vec2(0.0, 8.0))
|
|
.frame(false),
|
|
);
|
|
|
|
input_context(&response, clipboard, input, PasteBehavior::Append);
|
|
|
|
let mut requested_focus = false;
|
|
if focus_state == FocusState::ShouldRequestFocus {
|
|
response.request_focus();
|
|
requested_focus = true;
|
|
}
|
|
|
|
let after_len = input.len();
|
|
|
|
let input_changed = before_len != after_len;
|
|
|
|
SearchResponse {
|
|
requested_focus,
|
|
input_changed,
|
|
}
|
|
})
|
|
.inner
|
|
})
|
|
.inner
|
|
})
|
|
.inner
|
|
}
|
|
|
|
#[derive(Debug, Eq, PartialEq)]
|
|
pub enum SearchType {
|
|
String,
|
|
NoteId(NoteId),
|
|
Profile(Pubkey),
|
|
Hashtag(String),
|
|
}
|
|
|
|
impl SearchType {
|
|
fn get_type(query: &str) -> Self {
|
|
if query.len() == 63 && query.starts_with("note1") {
|
|
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 query.chars().nth(0).is_some_and(|c| c == '#') {
|
|
if let Some(hashtag) = query.get(1..) {
|
|
return SearchType::Hashtag(hashtag.to_string());
|
|
}
|
|
}
|
|
|
|
SearchType::String
|
|
}
|
|
|
|
fn search(
|
|
&self,
|
|
raw_query: &String,
|
|
ndb: &Ndb,
|
|
txn: &Transaction,
|
|
max_results: u64,
|
|
) -> Option<Vec<NoteRef>> {
|
|
match self {
|
|
SearchType::String => search_string(raw_query, ndb, txn, max_results),
|
|
SearchType::NoteId(noteid) => search_note(noteid, ndb, txn).map(|n| vec![n]),
|
|
SearchType::Profile(pk) => search_pk(pk, ndb, txn, max_results),
|
|
SearchType::Hashtag(hashtag) => search_hashtag(hashtag, ndb, txn, max_results),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn search_string(
|
|
query: &String,
|
|
ndb: &Ndb,
|
|
txn: &Transaction,
|
|
max_results: u64,
|
|
) -> Option<Vec<NoteRef>> {
|
|
let filter = Filter::new()
|
|
.search(query)
|
|
.kinds([1])
|
|
.limit(max_results)
|
|
.build();
|
|
|
|
// TODO: execute in thread
|
|
|
|
let before = Instant::now();
|
|
let qrs = ndb.query(txn, &[filter], max_results as i32);
|
|
let after = Instant::now();
|
|
let duration = after - before;
|
|
|
|
if duration > Duration::from_millis(20) {
|
|
warn!(
|
|
"query took {:?}... let's update this to use a thread!",
|
|
after - before
|
|
);
|
|
}
|
|
|
|
match qrs {
|
|
Ok(qrs) => {
|
|
info!("queried '{}' and got {} results", query, qrs.len());
|
|
|
|
return Some(qrs.into_iter().map(NoteRef::from_query_result).collect());
|
|
}
|
|
|
|
Err(err) => {
|
|
error!("fulltext query failed: {err}")
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn search_note(noteid: &NoteId, ndb: &Ndb, txn: &Transaction) -> Option<NoteRef> {
|
|
ndb.get_note_by_id(txn, noteid.bytes())
|
|
.ok()
|
|
.map(|n| NoteRef::from_note(&n))
|
|
}
|
|
|
|
fn search_pk(pk: &Pubkey, ndb: &Ndb, txn: &Transaction, max_results: u64) -> Option<Vec<NoteRef>> {
|
|
let filter = Filter::new()
|
|
.authors([pk.bytes()])
|
|
.kinds([1])
|
|
.limit(max_results)
|
|
.build();
|
|
|
|
let qrs = ndb.query(txn, &[filter], max_results as i32).ok()?;
|
|
Some(qrs.into_iter().map(NoteRef::from_query_result).collect())
|
|
}
|
|
|
|
fn search_hashtag(
|
|
hashtag_name: &str,
|
|
ndb: &Ndb,
|
|
txn: &Transaction,
|
|
max_results: u64,
|
|
) -> Option<Vec<NoteRef>> {
|
|
let filter = Filter::new()
|
|
.kinds([1])
|
|
.limit(max_results)
|
|
.tags([hashtag_name], 't')
|
|
.build();
|
|
|
|
let qrs = ndb.query(txn, &[filter], max_results as i32).ok()?;
|
|
Some(qrs.into_iter().map(NoteRef::from_query_result).collect())
|
|
}
|