notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 2+-
MCargo.toml | 2+-
Mcrates/enostr/src/note.rs | 10++++++++++
Mcrates/notedeck_chrome/src/app.rs | 1+
Mcrates/notedeck_chrome/src/chrome.rs | 17++++++++++++-----
Mcrates/notedeck_columns/src/actionbar.rs | 88++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/notedeck_columns/src/column.rs | 10+++++++---
Mcrates/notedeck_columns/src/nav.rs | 332++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/notedeck_columns/src/profile.rs | 14++++----------
Mcrates/notedeck_columns/src/route.rs | 37+++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 116++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mcrates/notedeck_columns/src/ui/search/mod.rs | 409+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/notedeck_columns/src/ui/search/state.rs | 29++++++++++-------------------
Mcrates/notedeck_columns/src/ui/thread.rs | 110+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mcrates/notedeck_columns/src/ui/timeline.rs | 10+++++++++-
Mcrates/notedeck_columns/src/ui/wallet.rs | 30++++++++++++++----------------
Mcrates/notedeck_ui/src/profile/picture.rs | 4++--
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, &note, 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, &note, 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,