commit fc51ddb43849a2b66c07549030693d322a3a3029
parent ee85b754dd86bce33ebf6b7c00a4a599be1da7e5
Author: William Casarin <jb55@jb55.com>
Date: Sun, 1 Jun 2025 00:07:19 +0200
Merge remote-tracking branches 'github/pr/864' and 'github/pr/866'
Diffstat:
17 files changed, 830 insertions(+), 391 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1468,7 +1468,7 @@ dependencies = [
[[package]]
name = "egui_nav"
version = "0.2.0"
-source = "git+https://github.com/damus-io/egui-nav?rev=5e816ac95e20f31dbb243a0d76179eab329a8ac0#5e816ac95e20f31dbb243a0d76179eab329a8ac0"
+source = "git+https://github.com/damus-io/egui-nav?rev=0f0cbdd3184f3ff5fdf69ada08416ffc58a70d7a#0f0cbdd3184f3ff5fdf69ada08416ffc58a70d7a"
dependencies = [
"egui",
"egui_extras",
diff --git a/Cargo.toml b/Cargo.toml
@@ -23,7 +23,7 @@ egui = { version = "0.31.1", features = ["serde"] }
egui-wgpu = "0.31.1"
egui_extras = { version = "0.31.1", features = ["all_loaders"] }
egui-winit = { version = "0.31.1", features = ["android-game-activity", "clipboard"] }
-egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "5e816ac95e20f31dbb243a0d76179eab329a8ac0" }
+egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "0f0cbdd3184f3ff5fdf69ada08416ffc58a70d7a" }
egui_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "881d86bdf8b424563bf0869eaab5ab9a69e012a4" }
#egui_virtual_list = "0.6.0"
egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b6794f5e707a2f4109633770e02b02fb722e1" }
diff --git a/crates/enostr/src/note.rs b/crates/enostr/src/note.rs
@@ -36,6 +36,16 @@ impl NoteId {
pub fn to_bech(&self) -> Option<String> {
bech32::encode::<bech32::Bech32>(HRP_NOTE, &self.0).ok()
}
+
+ pub fn from_bech(bech: &str) -> Option<Self> {
+ let (hrp, data) = bech32::decode(bech).ok()?;
+
+ if hrp != HRP_NOTE {
+ return None;
+ }
+
+ Some(NoteId::new(data.try_into().ok()?))
+ }
}
/// Event is the struct used to represent a Nostr event
diff --git a/crates/notedeck_chrome/src/app.rs b/crates/notedeck_chrome/src/app.rs
@@ -2,6 +2,7 @@ use notedeck::{AppAction, AppContext};
use notedeck_columns::Damus;
use notedeck_dave::Dave;
+#[allow(clippy::large_enum_variant)]
pub enum NotedeckApp {
Dave(Dave),
Columns(Damus),
diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs
@@ -500,13 +500,14 @@ fn chrome_handle_app_action(
let txn = Transaction::new(ctx.ndb).unwrap();
- notedeck_columns::actionbar::execute_and_process_note_action(
+ let cols = columns
+ .decks_cache
+ .active_columns_mut(ctx.accounts)
+ .unwrap();
+ let m_action = notedeck_columns::actionbar::execute_and_process_note_action(
note_action,
ctx.ndb,
- columns
- .decks_cache
- .active_columns_mut(ctx.accounts)
- .unwrap(),
+ cols,
0,
&mut columns.timeline_cache,
ctx.note_cache,
@@ -519,6 +520,12 @@ fn chrome_handle_app_action(
ctx.img_cache,
ui,
);
+
+ if let Some(action) = m_action {
+ let col = cols.column_mut(0);
+
+ action.process(&mut col.router, &mut col.sheet_router);
+ }
}
}
}
diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs
@@ -1,6 +1,7 @@
use crate::{
column::Columns,
- route::{Route, Router},
+ nav::{RouterAction, RouterType},
+ route::Route,
timeline::{ThreadSelection, TimelineCache, TimelineKind},
};
@@ -21,12 +22,16 @@ pub enum TimelineOpenResult {
NewNotes(NewNotes),
}
+struct NoteActionResponse {
+ timeline_res: Option<TimelineOpenResult>,
+ router_action: Option<RouterAction>,
+}
+
/// The note action executor for notedeck_columns
#[allow(clippy::too_many_arguments)]
fn execute_note_action(
action: NoteAction,
ndb: &Ndb,
- router: &mut Router<Route>,
timeline_cache: &mut TimelineCache,
note_cache: &mut NoteCache,
pool: &mut RelayPool,
@@ -35,43 +40,45 @@ fn execute_note_action(
global_wallet: &mut GlobalWallet,
zaps: &mut Zaps,
images: &mut Images,
+ router_type: RouterType,
ui: &mut egui::Ui,
-) -> Option<TimelineOpenResult> {
+) -> NoteActionResponse {
+ let mut timeline_res = None;
+ let mut router_action = None;
+
match action {
NoteAction::Reply(note_id) => {
- router.route_to(Route::reply(note_id));
- None
+ router_action = Some(RouterAction::route_to(Route::reply(note_id)));
}
NoteAction::Profile(pubkey) => {
let kind = TimelineKind::Profile(pubkey);
- router.route_to(Route::Timeline(kind.clone()));
- timeline_cache.open(ndb, note_cache, txn, pool, &kind)
+ router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone())));
+ timeline_res = timeline_cache.open(ndb, note_cache, txn, pool, &kind);
}
NoteAction::Note(note_id) => 'ex: {
let Ok(thread_selection) = ThreadSelection::from_note_id(ndb, note_cache, txn, note_id)
else {
tracing::error!("No thread selection for {}?", hex::encode(note_id.bytes()));
- break 'ex None;
+ break 'ex;
};
let kind = TimelineKind::Thread(thread_selection);
- router.route_to(Route::Timeline(kind.clone()));
+ router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone())));
// NOTE!!: you need the note_id to timeline root id thing
- timeline_cache.open(ndb, note_cache, txn, pool, &kind)
+ timeline_res = timeline_cache.open(ndb, note_cache, txn, pool, &kind);
}
NoteAction::Hashtag(htag) => {
let kind = TimelineKind::Hashtag(htag.clone());
- router.route_to(Route::Timeline(kind.clone()));
- timeline_cache.open(ndb, note_cache, txn, pool, &kind)
+ router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone())));
+ timeline_res = timeline_cache.open(ndb, note_cache, txn, pool, &kind);
}
NoteAction::Quote(note_id) => {
- router.route_to(Route::quote(note_id));
- None
+ router_action = Some(RouterAction::route_to(Route::quote(note_id)));
}
NoteAction::Zap(zap_action) => 's: {
let Some(cur_acc) = accounts.get_selected_account_mut() else {
- break 's None;
+ break 's;
};
let sender = cur_acc.key.pubkey;
@@ -88,6 +95,10 @@ fn execute_note_action(
break 'a;
};
+ if let RouterType::Sheet = router_type {
+ router_action = Some(RouterAction::GoBack);
+ }
+
send_zap(
&sender,
zaps,
@@ -98,26 +109,26 @@ fn execute_note_action(
}
ZapAction::ClearError(target) => clear_zap_error(&sender, zaps, target),
ZapAction::CustomizeAmount(target) => {
- router.route_to(Route::CustomizeZapAmount(target.to_owned()))
+ let route = Route::CustomizeZapAmount(target.to_owned());
+ router_action = Some(RouterAction::route_to_sheet(route));
}
}
-
- None
}
- NoteAction::Context(context) => {
- match ndb.get_note_by_key(txn, context.note_key) {
- Err(err) => tracing::error!("{err}"),
- Ok(note) => {
- context.action.process(ui, ¬e, pool);
- }
+ NoteAction::Context(context) => match ndb.get_note_by_key(txn, context.note_key) {
+ Err(err) => tracing::error!("{err}"),
+ Ok(note) => {
+ context.action.process(ui, ¬e, pool);
}
- None
- }
+ },
NoteAction::Media(media_action) => {
media_action.process(images);
- None
}
}
+
+ NoteActionResponse {
+ timeline_res,
+ router_action,
+ }
}
/// Execute a NoteAction and process the result
@@ -137,12 +148,20 @@ pub fn execute_and_process_note_action(
zaps: &mut Zaps,
images: &mut Images,
ui: &mut egui::Ui,
-) {
- let router = columns.column_mut(col).router_mut();
- if let Some(br) = execute_note_action(
+) -> Option<RouterAction> {
+ let router_type = {
+ let sheet_router = &mut columns.column_mut(col).sheet_router;
+
+ if sheet_router.route().is_some() {
+ RouterType::Sheet
+ } else {
+ RouterType::Stack
+ }
+ };
+
+ let resp = execute_note_action(
action,
ndb,
- router,
timeline_cache,
note_cache,
pool,
@@ -151,10 +170,15 @@ pub fn execute_and_process_note_action(
global_wallet,
zaps,
images,
+ router_type,
ui,
- ) {
+ );
+
+ if let Some(br) = resp.timeline_res {
br.process(ndb, note_cache, txn, timeline_cache, unknown_ids);
}
+
+ resp.router_action
}
fn send_zap(
diff --git a/crates/notedeck_columns/src/column.rs b/crates/notedeck_columns/src/column.rs
@@ -1,6 +1,6 @@
use crate::{
actionbar::TimelineOpenResult,
- route::{Route, Router},
+ route::{Route, Router, SingletonRouter},
timeline::{Timeline, TimelineCache, TimelineKind},
};
use enostr::RelayPool;
@@ -11,13 +11,17 @@ use tracing::warn;
#[derive(Clone, Debug)]
pub struct Column {
- router: Router<Route>,
+ pub router: Router<Route>,
+ pub sheet_router: SingletonRouter<Route>,
}
impl Column {
pub fn new(routes: Vec<Route>) -> Self {
let router = Router::new(routes);
- Column { router }
+ Column {
+ router,
+ sheet_router: SingletonRouter::default(),
+ }
}
pub fn router(&self) -> &Router<Route> {
diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs
@@ -7,7 +7,7 @@ use crate::{
profile::{ProfileAction, SaveProfileChanges},
profile_state::ProfileState,
relay_pool_manager::RelayPoolManager,
- route::Route,
+ route::{Route, Router, SingletonRouter},
timeline::{route::render_timeline_route, TimelineCache},
ui::{
self,
@@ -25,7 +25,7 @@ use crate::{
Damus,
};
-use egui_nav::{Nav, NavAction, NavResponse, NavUiType};
+use egui_nav::{Nav, NavAction, NavResponse, NavUiType, Percent, PopupResponse, PopupSheet};
use nostrdb::Transaction;
use notedeck::{
get_current_default_msats, get_current_wallet, AccountsAction, AppContext, NoteAction,
@@ -122,7 +122,10 @@ impl From<NoteAction> for RenderNavAction {
}
}
-pub type NotedeckNavResponse = NavResponse<Option<RenderNavAction>>;
+enum NotedeckNavResponse {
+ Popup(PopupResponse<Option<RenderNavAction>>),
+ Nav(NavResponse<Option<RenderNavAction>>),
+}
pub struct RenderNavResponse {
column: usize,
@@ -142,126 +145,207 @@ impl RenderNavResponse {
ctx: &mut AppContext<'_>,
ui: &mut egui::Ui,
) -> bool {
- let mut switching_occured: bool = false;
- let col = self.column;
-
- if let Some(action) = self.response.response.or(self.response.title_response) {
- // start returning when we're finished posting
- match action {
- RenderNavAction::Back => {
- app.columns_mut(ctx.accounts)
- .column_mut(col)
- .router_mut()
- .go_back();
- }
+ match self.response {
+ NotedeckNavResponse::Popup(nav_action) => {
+ process_popup_resp(nav_action, app, ctx, ui, self.column);
+ false
+ }
+ NotedeckNavResponse::Nav(nav_response) => {
+ process_nav_resp(app, ctx, ui, nav_response, self.column)
+ }
+ }
+ }
+}
- RenderNavAction::RemoveColumn => {
- let kinds_to_pop = app.columns_mut(ctx.accounts).delete_column(col);
+fn process_popup_resp(
+ action: PopupResponse<Option<RenderNavAction>>,
+ app: &mut Damus,
+ ctx: &mut AppContext<'_>,
+ ui: &mut egui::Ui,
+ col: usize,
+) -> bool {
+ let mut switching_occured = false;
+ if let Some(nav_action) = action.response {
+ switching_occured = process_render_nav_action(app, ctx, ui, col, nav_action);
+ }
- for kind in &kinds_to_pop {
- if let Err(err) = app.timeline_cache.pop(kind, ctx.ndb, ctx.pool) {
- error!("error popping timeline: {err}");
- }
- }
+ if let Some(NavAction::Returned) = action.action {
+ let column = app.columns_mut(ctx.accounts).column_mut(col);
+ column.sheet_router.clear();
+ } else if let Some(NavAction::Navigating) = action.action {
+ let column = app.columns_mut(ctx.accounts).column_mut(col);
+ column.sheet_router.navigating = false;
+ }
- switching_occured = true;
- }
+ switching_occured
+}
- RenderNavAction::PostAction(new_post_action) => {
- let txn = Transaction::new(ctx.ndb).expect("txn");
- match new_post_action.execute(ctx.ndb, &txn, ctx.pool, &mut app.drafts) {
- Err(err) => tracing::error!("Error executing post action: {err}"),
- Ok(_) => tracing::debug!("Post action executed"),
+fn process_nav_resp(
+ app: &mut Damus,
+ ctx: &mut AppContext<'_>,
+ ui: &mut egui::Ui,
+ response: NavResponse<Option<RenderNavAction>>,
+ col: usize,
+) -> bool {
+ let mut switching_occured: bool = false;
+
+ if let Some(action) = response.response.or(response.title_response) {
+ // start returning when we're finished posting
+
+ switching_occured = process_render_nav_action(app, ctx, ui, col, action);
+ }
+
+ if let Some(action) = response.action {
+ match action {
+ NavAction::Returned => {
+ let r = app
+ .columns_mut(ctx.accounts)
+ .column_mut(col)
+ .router_mut()
+ .pop();
+
+ if let Some(Route::Timeline(kind)) = &r {
+ if let Err(err) = app.timeline_cache.pop(kind, ctx.ndb, ctx.pool) {
+ error!("popping timeline had an error: {err} for {:?}", kind);
}
- get_active_columns_mut(ctx.accounts, &mut app.decks_cache)
- .column_mut(col)
- .router_mut()
- .go_back();
- }
+ };
- RenderNavAction::NoteAction(note_action) => {
- let txn = Transaction::new(ctx.ndb).expect("txn");
+ switching_occured = true;
+ }
- crate::actionbar::execute_and_process_note_action(
- note_action,
- ctx.ndb,
- get_active_columns_mut(ctx.accounts, &mut app.decks_cache),
- col,
- &mut app.timeline_cache,
- ctx.note_cache,
- ctx.pool,
- &txn,
- ctx.unknown_ids,
- ctx.accounts,
- ctx.global_wallet,
- ctx.zaps,
- ctx.img_cache,
- ui,
- );
+ NavAction::Navigated => {
+ let cur_router = app.columns_mut(ctx.accounts).column_mut(col).router_mut();
+ cur_router.navigating = false;
+ if cur_router.is_replacing() {
+ cur_router.remove_previous_routes();
}
+ switching_occured = true;
+ }
- RenderNavAction::SwitchingAction(switching_action) => {
- switching_occured = switching_action.process(
- &mut app.timeline_cache,
- &mut app.decks_cache,
- ctx,
- );
- }
- RenderNavAction::ProfileAction(profile_action) => {
- profile_action.process(
- &mut app.view_state.pubkey_to_profile_state,
- ctx.ndb,
- ctx.pool,
- get_active_columns_mut(ctx.accounts, &mut app.decks_cache)
- .column_mut(col)
- .router_mut(),
- );
- }
- RenderNavAction::WalletAction(wallet_action) => {
- let router = get_active_columns_mut(ctx.accounts, &mut app.decks_cache)
- .column_mut(col)
- .router_mut();
- wallet_action.process(ctx.accounts, ctx.global_wallet, router)
+ NavAction::Dragging => {}
+ NavAction::Returning => {}
+ NavAction::Resetting => {}
+ NavAction::Navigating => {}
+ }
+ }
+
+ switching_occured
+}
+
+pub enum RouterAction {
+ GoBack,
+ RouteTo(Route, RouterType),
+}
+
+pub enum RouterType {
+ Sheet,
+ Stack,
+}
+
+impl RouterAction {
+ pub fn process(
+ self,
+ stack_router: &mut Router<Route>,
+ sheet_router: &mut SingletonRouter<Route>,
+ ) {
+ match self {
+ RouterAction::GoBack => {
+ if sheet_router.route().is_some() {
+ sheet_router.go_back();
+ } else {
+ stack_router.go_back();
}
}
+ RouterAction::RouteTo(route, router_type) => match router_type {
+ RouterType::Sheet => sheet_router.route_to(route),
+ RouterType::Stack => stack_router.route_to(route),
+ },
}
+ }
- if let Some(action) = self.response.action {
- match action {
- NavAction::Returned => {
- let r = app
- .columns_mut(ctx.accounts)
- .column_mut(col)
- .router_mut()
- .pop();
-
- if let Some(Route::Timeline(kind)) = &r {
- if let Err(err) = app.timeline_cache.pop(kind, ctx.ndb, ctx.pool) {
- error!("popping timeline had an error: {err} for {:?}", kind);
- }
- };
+ pub fn route_to(route: Route) -> Self {
+ RouterAction::RouteTo(route, RouterType::Stack)
+ }
- switching_occured = true;
- }
+ pub fn route_to_sheet(route: Route) -> Self {
+ RouterAction::RouteTo(route, RouterType::Sheet)
+ }
+}
- NavAction::Navigated => {
- let cur_router = app.columns_mut(ctx.accounts).column_mut(col).router_mut();
- cur_router.navigating = false;
- if cur_router.is_replacing() {
- cur_router.remove_previous_routes();
- }
- switching_occured = true;
+fn process_render_nav_action(
+ app: &mut Damus,
+ ctx: &mut AppContext<'_>,
+ ui: &mut egui::Ui,
+ col: usize,
+ action: RenderNavAction,
+) -> bool {
+ let router_action = match action {
+ RenderNavAction::Back => Some(RouterAction::GoBack),
+
+ RenderNavAction::RemoveColumn => {
+ let kinds_to_pop = app.columns_mut(ctx.accounts).delete_column(col);
+
+ for kind in &kinds_to_pop {
+ if let Err(err) = app.timeline_cache.pop(kind, ctx.ndb, ctx.pool) {
+ error!("error popping timeline: {err}");
}
+ }
- NavAction::Dragging => {}
- NavAction::Returning => {}
- NavAction::Resetting => {}
- NavAction::Navigating => {}
+ return true;
+ }
+
+ RenderNavAction::PostAction(new_post_action) => {
+ let txn = Transaction::new(ctx.ndb).expect("txn");
+ match new_post_action.execute(ctx.ndb, &txn, ctx.pool, &mut app.drafts) {
+ Err(err) => tracing::error!("Error executing post action: {err}"),
+ Ok(_) => tracing::debug!("Post action executed"),
}
+
+ Some(RouterAction::GoBack)
}
- switching_occured
+ RenderNavAction::NoteAction(note_action) => {
+ let txn = Transaction::new(ctx.ndb).expect("txn");
+
+ crate::actionbar::execute_and_process_note_action(
+ note_action,
+ ctx.ndb,
+ get_active_columns_mut(ctx.accounts, &mut app.decks_cache),
+ col,
+ &mut app.timeline_cache,
+ ctx.note_cache,
+ ctx.pool,
+ &txn,
+ ctx.unknown_ids,
+ ctx.accounts,
+ ctx.global_wallet,
+ ctx.zaps,
+ ctx.img_cache,
+ ui,
+ )
+ }
+
+ RenderNavAction::SwitchingAction(switching_action) => {
+ return switching_action.process(&mut app.timeline_cache, &mut app.decks_cache, ctx);
+ }
+ RenderNavAction::ProfileAction(profile_action) => profile_action.process(
+ &mut app.view_state.pubkey_to_profile_state,
+ ctx.ndb,
+ ctx.pool,
+ ),
+ RenderNavAction::WalletAction(wallet_action) => {
+ wallet_action.process(ctx.accounts, ctx.global_wallet)
+ }
+ };
+
+ if let Some(action) = router_action {
+ let cols = get_active_columns_mut(ctx.accounts, &mut app.decks_cache).column_mut(col);
+ let router = &mut cols.router;
+ let sheet_router = &mut cols.sheet_router;
+ action.process(router, sheet_router);
}
+
+ false
}
fn render_nav_body(
@@ -626,6 +710,48 @@ pub fn render_nav(
ctx: &mut AppContext<'_>,
ui: &mut egui::Ui,
) -> RenderNavResponse {
+ if let Some(sheet_route) = app
+ .columns(ctx.accounts)
+ .column(col)
+ .sheet_router
+ .route()
+ .clone()
+ {
+ let navigating = app
+ .columns(ctx.accounts)
+ .column(col)
+ .sheet_router
+ .navigating;
+ let returning = app.columns(ctx.accounts).column(col).sheet_router.returning;
+ let bg_route = app
+ .columns(ctx.accounts)
+ .column(col)
+ .router()
+ .routes()
+ .last()
+ .cloned();
+ if let Some(bg_route) = bg_route {
+ let resp = PopupSheet::new(&bg_route, &sheet_route)
+ .id_source(egui::Id::new(("nav", col)))
+ .navigating(navigating)
+ .returning(returning)
+ .with_split_percent_from_top(Percent::new(35).expect("35 <= 100"))
+ .show_mut(ui, |ui, typ, route| match typ {
+ NavUiType::Title => NavTitle::new(
+ ctx.ndb,
+ ctx.img_cache,
+ get_active_columns_mut(ctx.accounts, &mut app.decks_cache),
+ &[route.clone()],
+ col,
+ )
+ .show(ui),
+ NavUiType::Body => render_nav_body(ui, app, ctx, route, 1, col, inner_rect),
+ });
+
+ return RenderNavResponse::new(col, NotedeckNavResponse::Popup(resp));
+ }
+ };
+
let nav_response = Nav::new(
&app.columns(ctx.accounts)
.column(col)
@@ -664,5 +790,5 @@ pub fn render_nav(
}
});
- RenderNavResponse::new(col, nav_response)
+ RenderNavResponse::new(col, NotedeckNavResponse::Nav(nav_response))
}
diff --git a/crates/notedeck_columns/src/profile.rs b/crates/notedeck_columns/src/profile.rs
@@ -5,10 +5,7 @@ use nostrdb::{Ndb, Note, NoteBuildOptions, NoteBuilder};
use tracing::info;
-use crate::{
- profile_state::ProfileState,
- route::{Route, Router},
-};
+use crate::{nav::RouterAction, profile_state::ProfileState, route::Route};
pub struct SaveProfileChanges {
pub kp: FullKeypair,
@@ -48,12 +45,9 @@ impl ProfileAction {
state_map: &mut HashMap<Pubkey, ProfileState>,
ndb: &Ndb,
pool: &mut RelayPool,
- router: &mut Router<Route>,
- ) {
+ ) -> Option<RouterAction> {
match self {
- ProfileAction::Edit(kp) => {
- router.route_to(Route::EditProfile(kp.pubkey));
- }
+ ProfileAction::Edit(kp) => Some(RouterAction::route_to(Route::EditProfile(kp.pubkey))),
ProfileAction::SaveChanges(changes) => {
let raw_msg = format!("[\"EVENT\",{}]", changes.to_note().json().unwrap());
@@ -66,7 +60,7 @@ impl ProfileAction {
info!("sending {}", raw_msg);
pool.send(&enostr::ClientMessage::raw(raw_msg));
- router.go_back();
+ Some(RouterAction::GoBack)
}
}
}
diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs
@@ -361,3 +361,40 @@ impl fmt::Display for Route {
}
}
}
+
+#[derive(Clone, Debug)]
+pub struct SingletonRouter<R: Clone> {
+ route: Option<R>,
+ pub returning: bool,
+ pub navigating: bool,
+}
+
+impl<R: Clone> SingletonRouter<R> {
+ pub fn route_to(&mut self, route: R) {
+ self.navigating = true;
+ self.route = Some(route);
+ }
+
+ pub fn go_back(&mut self) {
+ self.returning = true;
+ }
+
+ pub fn clear(&mut self) {
+ self.route = None;
+ self.returning = false;
+ }
+
+ pub fn route(&self) -> &Option<R> {
+ &self.route
+ }
+}
+
+impl<R: Clone> Default for SingletonRouter<R> {
+ fn default() -> Self {
+ Self {
+ route: None,
+ returning: false,
+ navigating: false,
+ }
+ }
+}
diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs
@@ -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();
+
+ 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));
- action
- })
- .inner
+ output.inner
}
fn profile_body(&mut self, ui: &mut egui::Ui, profile: ProfileRecord<'_>) -> bool {
diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs
@@ -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,
+ );
+
+ 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);
- match self.query.state {
- SearchState::New | SearchState::Navigating => None,
-
- 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
- ));
- }
-
- 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
+ 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() {
- return;
+ 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,
}
+ }
+}
- 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
- );
+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;
}
- match qrs {
- Ok(qrs) => {
- info!(
- "queried '{}' and got {} results",
- self.query.string,
- qrs.len()
- );
+ if state.string.chars().nth(0) != Some('@') {
+ if self.input_changed {
+ state.state = SearchState::Typing(TypingType::AutoSearch);
+ state.debouncer.bounce();
+ }
- 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();
+ if state.state == SearchState::Typing(TypingType::AutoSearch)
+ && state.debouncer.should_act()
+ {
+ state.state = SearchState::PerformSearch(SearchType::get_type(&state.string));
}
- Err(err) => {
- error!("fulltext query failed: {err}")
+ 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(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())
+}
diff --git a/crates/notedeck_columns/src/ui/search/state.rs b/crates/notedeck_columns/src/ui/search/state.rs
@@ -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;
- }
}
diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs
@@ -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,
-
- Err(err) => {
- ui.label(format!("Error loading thread: {:?}", err));
- return None;
- }
- };
-
- 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();
-
- // 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}");
+ .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible);
+
+ let offset_id = self.id_source.with("scroll_offset");
+
+ 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 root_id = match RootNoteId::new(
+ self.note_context.ndb,
+ self.note_context.note_cache,
+ &txn,
+ self.selected_note_id,
+ ) {
+ Ok(root_id) => root_id,
+
+ 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
}
}
diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs
@@ -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;
diff --git a/crates/notedeck_columns/src/ui/wallet.rs b/crates/notedeck_columns/src/ui/wallet.rs
@@ -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
}
}
diff --git a/crates/notedeck_ui/src/profile/picture.rs b/crates/notedeck_ui/src/profile/picture.rs
@@ -3,7 +3,7 @@ use crate::images::{fetch_no_pfp_promise, get_render_state, ImageType};
use egui::{vec2, InnerResponse, Sense, Stroke, TextureHandle};
use notedeck::note::MediaAction;
-use notedeck::{supported_mime_hosted_at_url, Images};
+use notedeck::{show_one_error_message, supported_mime_hosted_at_url, Images};
pub struct ProfilePic<'cache, 'url> {
cache: &'cache mut Images,
@@ -121,7 +121,7 @@ fn render_pfp(
}
notedeck::TextureState::Error(e) => {
paint_circle(ui, ui_size, border);
- tracing::error!("Failed to fetch profile at url {url}: {e}");
+ show_one_error_message(ui, &format!("Failed to fetch profile at url {url}: {e}"));
Some(MediaAction::FetchImage {
url: url.to_owned(),
cache_type,