Merge remote-tracking branches 'github/pr/864' and 'github/pr/866'

This commit is contained in:
William Casarin
2025-06-01 00:07:19 +02:00
17 changed files with 846 additions and 407 deletions

View File

@@ -65,67 +65,75 @@ impl<'a, 'd> ProfileView<'a, 'd> {
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<ProfileViewAction> {
let scroll_id = egui::Id::new(("profile_scroll", self.col_id, self.pubkey));
let offset_id = scroll_id.with("scroll_offset");
ScrollArea::vertical()
.id_salt(scroll_id)
.show(ui, |ui| {
let mut action = None;
let txn = Transaction::new(self.note_context.ndb).expect("txn");
if let Ok(profile) = self
.note_context
.ndb
.get_profile_by_pubkey(&txn, self.pubkey.bytes())
{
if self.profile_body(ui, profile) {
action = Some(ProfileViewAction::EditProfile);
}
let mut scroll_area = ScrollArea::vertical().id_salt(scroll_id);
if let Some(offset) = ui.data(|i| i.get_temp::<f32>(offset_id)) {
scroll_area = scroll_area.vertical_scroll_offset(offset);
}
let output = scroll_area.show(ui, |ui| {
let mut action = None;
let txn = Transaction::new(self.note_context.ndb).expect("txn");
if let Ok(profile) = self
.note_context
.ndb
.get_profile_by_pubkey(&txn, self.pubkey.bytes())
{
if self.profile_body(ui, profile) {
action = Some(ProfileViewAction::EditProfile);
}
let profile_timeline = self
.timeline_cache
.notes(
self.note_context.ndb,
self.note_context.note_cache,
&txn,
&TimelineKind::Profile(*self.pubkey),
)
.get_ptr();
profile_timeline.selected_view =
tabs_ui(ui, profile_timeline.selected_view, &profile_timeline.views);
let reversed = false;
// poll for new notes and insert them into our existing notes
if let Err(e) = profile_timeline.poll_notes_into_view(
}
let profile_timeline = self
.timeline_cache
.notes(
self.note_context.ndb,
&txn,
self.unknown_ids,
self.note_context.note_cache,
reversed,
) {
error!("Profile::poll_notes_into_view: {e}");
}
if let Some(note_action) = TimelineTabView::new(
profile_timeline.current_view(),
reversed,
self.note_options,
&txn,
self.is_muted,
self.note_context,
&self
.accounts
.get_selected_account()
.map(|a| (&a.key).into()),
self.jobs,
&TimelineKind::Profile(*self.pubkey),
)
.show(ui)
{
action = Some(ProfileViewAction::Note(note_action));
}
.get_ptr();
action
})
.inner
profile_timeline.selected_view =
tabs_ui(ui, profile_timeline.selected_view, &profile_timeline.views);
let reversed = false;
// poll for new notes and insert them into our existing notes
if let Err(e) = profile_timeline.poll_notes_into_view(
self.note_context.ndb,
&txn,
self.unknown_ids,
self.note_context.note_cache,
reversed,
) {
error!("Profile::poll_notes_into_view: {e}");
}
if let Some(note_action) = TimelineTabView::new(
profile_timeline.current_view(),
reversed,
self.note_options,
&txn,
self.is_muted,
self.note_context,
&self
.accounts
.get_selected_account()
.map(|a| (&a.key).into()),
self.jobs,
)
.show(ui)
{
action = Some(ProfileViewAction::Note(note_action));
}
action
});
ui.data_mut(|d| d.insert_temp(offset_id, output.state.offset.y));
output.inner
}
fn profile_body(&mut self, ui: &mut egui::Ui, profile: ProfileRecord<'_>) -> bool {

View File

@@ -1,9 +1,10 @@
use egui::{vec2, Align, Color32, CornerRadius, RichText, Stroke, TextEdit};
use enostr::KeypairUnowned;
use enostr::{KeypairUnowned, NoteId, Pubkey};
use state::TypingType;
use crate::ui::timeline::TimelineTabView;
use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView};
use egui_winit::clipboard::Clipboard;
use nostrdb::{Filter, Transaction};
use nostrdb::{Filter, Ndb, Transaction};
use notedeck::{MuteFun, NoteAction, NoteContext, NoteRef};
use notedeck_ui::{icons::search_icon, jobs::JobsCache, padding, NoteOptions};
use std::time::{Duration, Instant};
@@ -13,6 +14,8 @@ 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,
@@ -55,95 +58,201 @@ impl<'a, 'd> SearchView<'a, 'd> {
) -> Option<NoteAction> {
ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0);
if search_box(self.query, ui, clipboard) {
self.execute_search(ui.ctx());
}
let search_resp = search_box(
&mut self.query.string,
self.query.focus_state.clone(),
ui,
clipboard,
);
match self.query.state {
SearchState::New | SearchState::Navigating => None,
search_resp.process(self.query);
SearchState::Searched | SearchState::Typing => {
if self.query.state == SearchState::Typing {
ui.label(format!("Searching for '{}'", &self.query.string));
} else {
ui.label(format!(
"Got {} results for '{}'",
self.query.notes.notes.len(),
&self.query.string
));
}
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;
};
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
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 execute_search(&mut self, ctx: &egui::Context) {
if self.query.string.is_empty() {
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;
}
let max_results = 500;
let filter = Filter::new()
.search(&self.query.string)
.kinds([1])
.limit(max_results)
.build();
// TODO: execute in thread
let before = Instant::now();
let qrs = self
.note_context
.ndb
.query(self.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",
self.query.string,
qrs.len()
);
let note_refs = qrs.into_iter().map(NoteRef::from_query_result).collect();
self.query.notes.notes = note_refs;
self.query.notes.list.borrow_mut().reset();
ctx.request_repaint();
}
Err(err) => {
error!("fulltext query failed: {err}")
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(query: &mut SearchQueryState, ui: &mut egui::Ui, clipboard: &mut Clipboard) -> bool {
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 {
@@ -165,13 +274,13 @@ fn search_box(query: &mut SearchQueryState, ui: &mut egui::Ui, clipboard: &mut C
// Magnifying glass icon
ui.add(search_icon(16.0, search_height));
let before_len = query.string.len();
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(&mut query.string)
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))
@@ -182,37 +291,32 @@ fn search_box(query: &mut SearchQueryState, ui: &mut egui::Ui, clipboard: &mut C
response.context_menu(|ui| {
if ui.button("paste").clicked() {
if let Some(text) = clipboard.get() {
query.string.clear();
query.string.push_str(&text);
input.clear();
input.push_str(&text);
}
}
});
if response.middle_clicked() {
if let Some(text) = clipboard.get() {
query.string.clear();
query.string.push_str(&text);
input.clear();
input.push_str(&text);
}
}
if query.focus_state == FocusState::ShouldRequestFocus {
let mut requested_focus = false;
if focus_state == FocusState::ShouldRequestFocus {
response.request_focus();
query.focus_state = FocusState::RequestedFocus;
requested_focus = true;
}
let after_len = query.string.len();
let after_len = input.len();
let changed = before_len != after_len;
if changed {
query.mark_updated();
}
let input_changed = before_len != after_len;
// Execute search after debouncing
if query.should_search() {
query.mark_searched(SearchState::Searched);
true
} else {
false
SearchResponse {
requested_focus,
input_changed,
}
})
.inner
@@ -221,3 +325,120 @@ fn search_box(query: &mut SearchQueryState, ui: &mut egui::Ui, clipboard: &mut C
})
.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())
}

View File

@@ -2,15 +2,24 @@ use crate::timeline::TimelineTab;
use notedeck::debouncer::Debouncer;
use std::time::Duration;
use super::SearchType;
#[derive(Debug, Eq, PartialEq)]
pub enum SearchState {
Typing,
Typing(TypingType),
PerformSearch(SearchType),
Searched,
Navigating,
New,
}
#[derive(Debug, Eq, PartialEq)]
pub enum TypingType {
Mention(String),
AutoSearch,
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum FocusState {
/// Get ready to focus
Navigating,
@@ -60,22 +69,4 @@ impl SearchQueryState {
debouncer: Debouncer::new(Duration::from_millis(200)),
}
}
pub fn should_search(&self) -> bool {
self.state == SearchState::Typing && self.debouncer.should_act()
}
/// Mark the search as updated. This will update our debouncer and clear
/// the searched flag, enabling us to search again. This should be
/// called when the search box changes
pub fn mark_updated(&mut self) {
self.state = SearchState::Typing;
self.debouncer.bounce();
}
/// Call this when you are about to do a search so that we don't try
/// to search again next frame
pub fn mark_searched(&mut self, state: SearchState) {
self.state = state;
}
}

View File

@@ -54,62 +54,72 @@ impl<'a, 'd> ThreadView<'a, 'd> {
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> {
let txn = Transaction::new(self.note_context.ndb).expect("txn");
egui::ScrollArea::vertical()
let mut scroll_area = egui::ScrollArea::vertical()
.id_salt(self.id_source)
.animated(false)
.auto_shrink([false, false])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible)
.show(ui, |ui| {
let root_id = match RootNoteId::new(
self.note_context.ndb,
self.note_context.note_cache,
&txn,
self.selected_note_id,
) {
Ok(root_id) => root_id,
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible);
Err(err) => {
ui.label(format!("Error loading thread: {:?}", err));
return None;
}
};
let offset_id = self.id_source.with("scroll_offset");
let thread_timeline = self
.timeline_cache
.notes(
self.note_context.ndb,
self.note_context.note_cache,
&txn,
&TimelineKind::Thread(ThreadSelection::from_root_id(root_id.to_owned())),
)
.get_ptr();
if let Some(offset) = ui.data(|i| i.get_temp::<f32>(offset_id)) {
scroll_area = scroll_area.vertical_scroll_offset(offset);
}
// TODO(jb55): skip poll if ThreadResult is fresh?
let output = scroll_area.show(ui, |ui| {
let root_id = match RootNoteId::new(
self.note_context.ndb,
self.note_context.note_cache,
&txn,
self.selected_note_id,
) {
Ok(root_id) => root_id,
let reversed = true;
// poll for new notes and insert them into our existing notes
if let Err(err) = thread_timeline.poll_notes_into_view(
self.note_context.ndb,
&txn,
self.unknown_ids,
self.note_context.note_cache,
reversed,
) {
error!("error polling notes into thread timeline: {err}");
Err(err) => {
ui.label(format!("Error loading thread: {:?}", err));
return None;
}
};
TimelineTabView::new(
thread_timeline.current_view(),
true,
self.note_options,
let thread_timeline = self
.timeline_cache
.notes(
self.note_context.ndb,
self.note_context.note_cache,
&txn,
self.is_muted,
self.note_context,
self.cur_acc,
self.jobs,
&TimelineKind::Thread(ThreadSelection::from_root_id(root_id.to_owned())),
)
.show(ui)
})
.inner
.get_ptr();
// TODO(jb55): skip poll if ThreadResult is fresh?
let reversed = true;
// poll for new notes and insert them into our existing notes
if let Err(err) = thread_timeline.poll_notes_into_view(
self.note_context.ndb,
&txn,
self.unknown_ids,
self.note_context.note_cache,
reversed,
) {
error!("error polling notes into thread timeline: {err}");
}
TimelineTabView::new(
thread_timeline.current_view(),
true,
self.note_options,
&txn,
self.is_muted,
self.note_context,
self.cur_acc,
self.jobs,
)
.show(ui)
});
ui.data_mut(|d| d.insert_temp(offset_id, output.state.offset.y));
output.inner
}
}

View File

@@ -130,6 +130,12 @@ fn timeline_ui(
.auto_shrink([false, false])
.scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible);
let offset_id = scroll_id.with("timeline_scroll_offset");
if let Some(offset) = ui.data(|i| i.get_temp::<f32>(offset_id)) {
scroll_area = scroll_area.vertical_scroll_offset(offset);
}
if let Some(goto_top_resp) = goto_top_resp {
if goto_top_resp.clicked() {
scroll_area = scroll_area.vertical_scroll_offset(0.0);
@@ -163,6 +169,8 @@ fn timeline_ui(
.show(ui)
});
ui.data_mut(|d| d.insert_temp(offset_id, scroll_output.state.offset.y));
let at_top_after_scroll = scroll_output.state.offset.y == 0.0;
let cur_show_top_button = ui.ctx().data(|d| d.get_temp::<bool>(show_top_button_id));
@@ -362,9 +370,9 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
let len = self.tab.notes.len();
let is_muted = self.is_muted;
self.tab
.list
.clone()
.borrow_mut()
.ui_custom_layout(ui, len, |ui, start_index| {
ui.spacing_mut().item_spacing.y = 0.0;

View File

@@ -4,7 +4,7 @@ use notedeck::{
PendingDefaultZapState, Wallet, WalletError, WalletUIState, ZapWallet,
};
use crate::route::{Route, Router};
use crate::{nav::RouterAction, route::Route};
use super::widgets::styled_button;
@@ -55,43 +55,40 @@ impl WalletAction {
&self,
accounts: &mut Accounts,
global_wallet: &mut GlobalWallet,
router: &mut Router<Route>,
) {
) -> Option<RouterAction> {
let mut action = None;
match &self {
WalletAction::SaveURI => {
let ui_state = &mut global_wallet.ui_state;
if ui_state.for_local_only {
ui_state.for_local_only = false;
let Some(cur_acc) = accounts.get_selected_account_mut() else {
return;
};
let cur_acc = accounts.get_selected_account_mut()?;
if cur_acc.wallet.is_some() {
return;
return None;
}
let Some(wallet) = try_create_wallet(ui_state) else {
return;
};
let wallet = try_create_wallet(ui_state)?;
accounts.update_current_account(move |acc| {
acc.wallet = Some(wallet.into());
});
} else {
if global_wallet.wallet.is_some() {
return;
return None;
}
let Some(wallet) = try_create_wallet(ui_state) else {
return;
};
let wallet = try_create_wallet(ui_state)?;
global_wallet.wallet = Some(wallet.into());
global_wallet.save_wallet();
}
}
WalletAction::AddLocalOnly => {
router.route_to(Route::Wallet(notedeck::WalletType::Local));
action = Some(RouterAction::route_to(Route::Wallet(
notedeck::WalletType::Local,
)));
global_wallet.ui_state.for_local_only = true;
}
WalletAction::Delete => {
@@ -100,7 +97,7 @@ impl WalletAction {
accounts.update_current_account(|acc| {
acc.wallet = None;
});
return;
return None;
}
}
@@ -153,6 +150,7 @@ impl WalletAction {
(wallet.default_zap.get_default_zap_msats() / 1000).to_string();
}
}
action
}
}