notedeck

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

commit 54dcbd724bc823377c17e4fd6c352d2749f87689
parent a17b2dcb174cae4956f13736e90334e08e895a4e
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 11 Oct 2024 12:56:11 +0200

Merge 'column titlebar #345'

William Casarin (2):
      update to use upstream egui-nav branch

kernelkind (13):
      basic add column impl
      remote sub new timeline
      add more add column options
      animate add column options
      push column picker immediately to new column
      move get first router to Columns
      tmp use kernelkind egui-nav
      title bar
      unsubscribe timeline on deletion
      fix deck author bug & rename titles
      tmp: kernelkind/egui-nav
      updated back arrow
      tmp: kernelkind/egui-nav

Diffstat:
MCargo.lock | 17++++++++++++-----
MCargo.toml | 3++-
Aassets/icons/column_delete_icon_4x.png | 0
Aassets/icons/home_icon_dark_4x.png | 0
Aassets/icons/notifications_icon_dark_4x.png | 0
Aassets/icons/universe_icon_dark_4x.png | 0
Msrc/app.rs | 164+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Msrc/app_style.rs | 13+++++++++++--
Msrc/column.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/fonts.rs | 15++++++++++++---
Msrc/nav.rs | 344+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/route.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/timeline/kind.rs | 31++++++++++++++++++++++++++++++-
Msrc/timeline/mod.rs | 10+++++-----
Msrc/timeline/route.rs | 18++++++++++--------
Asrc/ui/add_column.rs | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/anim.rs | 25+++++++++++++++++++++++++
Msrc/ui/mod.rs | 1+
Msrc/ui/profile/preview.rs | 26++++++++++++++++++++++++++
Msrc/ui/side_panel.rs | 23+++++++++++------------
Msrc/ui_preview/main.rs | 2++
21 files changed, 964 insertions(+), 174 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1148,7 +1148,7 @@ dependencies = [ [[package]] name = "egui_nav" version = "0.1.0" -source = "git+https://github.com/damus-io/egui-nav?rev=b19742503329a13df660ac8c5a3ada4a25b7cc53#b19742503329a13df660ac8c5a3ada4a25b7cc53" +source = "git+https://github.com/damus-io/egui-nav?rev=6ba42de2bae384d10e35c532f3856b81d2e9f645#6ba42de2bae384d10e35c532f3856b81d2e9f645" dependencies = [ "egui", "egui_extras", @@ -1660,7 +1660,7 @@ checksum = "9c08c1f623a8d0b722b8b99f821eb0ba672a1618f0d3b16ddbee1cedd2dd8557" dependencies = [ "bitflags 2.6.0", "gpu-descriptor-types", - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -1693,6 +1693,12 @@ dependencies = [ ] [[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + +[[package]] name = "hassle-rs" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1950,12 +1956,12 @@ checksum = "44feda355f4159a7c757171a77de25daf6411e217b4cabd03bd6650690468126" [[package]] name = "indexmap" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.0", "serde", ] @@ -2508,6 +2514,7 @@ dependencies = [ "env_logger 0.10.2", "hex", "image", + "indexmap", "log", "nostrdb", "poll-promise", diff --git a/Cargo.toml b/Cargo.toml @@ -18,7 +18,7 @@ eframe = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da egui_extras = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "egui_extras", features = ["all_loaders"] } ehttp = "0.2.0" egui_tabs = { git = "https://github.com/damus-io/egui-tabs", branch = "egui-0.28" } -egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "b19742503329a13df660ac8c5a3ada4a25b7cc53" } +egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "6ba42de2bae384d10e35c532f3856b81d2e9f645" } egui_virtual_list = { git = "https://github.com/jb55/hello_egui", branch = "egui-0.28", package = "egui_virtual_list" } reqwest = { version = "0.12.4", default-features = false, features = [ "rustls-tls-native-roots" ] } image = { version = "0.25", features = ["jpeg", "png", "webp"] } @@ -42,6 +42,7 @@ strum = "0.26" strum_macros = "0.26" bitflags = "2.5.0" uuid = { version = "1.10.0", features = ["v4"] } +indexmap = "2.6.0" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/assets/icons/column_delete_icon_4x.png b/assets/icons/column_delete_icon_4x.png Binary files differ. diff --git a/assets/icons/home_icon_dark_4x.png b/assets/icons/home_icon_dark_4x.png Binary files differ. diff --git a/assets/icons/notifications_icon_dark_4x.png b/assets/icons/notifications_icon_dark_4x.png Binary files differ. diff --git a/assets/icons/universe_icon_dark_4x.png b/assets/icons/universe_icon_dark_4x.png Binary files differ. diff --git a/src/app.rs b/src/app.rs @@ -3,21 +3,19 @@ use crate::{ app_creation::setup_cc, app_style::user_requested_visuals_change, args::Args, - column::{Column, Columns}, + column::Columns, draft::Drafts, error::{Error, FilterError}, - filter, - filter::FilterState, + filter::{self, FilterState}, frame_history::FrameHistory, imgcache::ImageCache, key_storage::KeyStorageType, nav, note::NoteRef, notecache::{CachedNote, NoteCache}, - route::Route, subscriptions::{SubKind, Subscriptions}, thread::Threads, - timeline::{Timeline, TimelineKind, ViewFilter}, + timeline::{Timeline, TimelineId, TimelineKind, ViewFilter}, ui::{self, DesktopSidePanel}, unknowns::UnknownIds, view_state::ViewState, @@ -41,6 +39,7 @@ use tracing::{debug, error, info, trace, warn}; pub enum DamusState { Initializing, Initialized, + NewTimelineSub(TimelineId), } /// We derive Deserialize/Serialize so we can persist app state on shutdown. @@ -248,7 +247,7 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { if let Err(err) = Timeline::poll_notes_into_view( timeline_ind, - &mut damus.columns.timelines, + damus.columns.timelines_mut(), &damus.ndb, &txn, &mut damus.unknown_ids, @@ -394,47 +393,105 @@ fn setup_initial_nostrdb_subs( columns: &mut Columns, ) -> Result<()> { for timeline in columns.timelines_mut() { - match &timeline.filter { - FilterState::Ready(filters) => { - { setup_initial_timeline(ndb, timeline, note_cache, &filters.clone()) }? - } + setup_nostrdb_sub(ndb, note_cache, timeline)? + } - FilterState::Broken(err) => { - error!("FetchingRemote state broken in setup_initial_nostr_subs: {err}") - } - FilterState::FetchingRemote(_) => { - error!("FetchingRemote state in setup_initial_nostr_subs") - } - FilterState::GotRemote(_) => { - error!("GotRemote state in setup_initial_nostr_subs") - } - FilterState::NeedsRemote(_filters) => { - // can't do anything yet, we defer to first connect to send - // remote filters + Ok(()) +} + +fn setup_nostrdb_sub(ndb: &Ndb, note_cache: &mut NoteCache, timeline: &mut Timeline) -> Result<()> { + match &timeline.filter { + FilterState::Ready(filters) => { + { setup_initial_timeline(ndb, timeline, note_cache, &filters.clone()) }? + } + + FilterState::Broken(err) => { + error!("FetchingRemote state broken in setup_initial_nostr_subs: {err}") + } + FilterState::FetchingRemote(_) => { + error!("FetchingRemote state in setup_initial_nostr_subs") + } + FilterState::GotRemote(_) => { + error!("GotRemote state in setup_initial_nostr_subs") + } + FilterState::NeedsRemote(_filters) => { + // can't do anything yet, we defer to first connect to send + // remote filters + } + } + + Ok(()) +} + +fn setup_new_nostrdb_sub( + ndb: &Ndb, + note_cache: &mut NoteCache, + columns: &mut Columns, + new_timeline_id: TimelineId, +) -> Result<()> { + if let Some(timeline) = columns.find_timeline_mut(new_timeline_id) { + info!("Setting up timeline sub for {}", timeline.id); + if let FilterState::Ready(filters) = &timeline.filter { + for filter in filters { + info!("Setting up filter {:?}", filter.json()); } } + setup_nostrdb_sub(ndb, note_cache, timeline)? } Ok(()) } fn update_damus(damus: &mut Damus, ctx: &egui::Context) { - if damus.state == DamusState::Initializing { - #[cfg(feature = "profiling")] - setup_profiling(); + match damus.state { + DamusState::Initializing => { + #[cfg(feature = "profiling")] + setup_profiling(); + + damus.state = DamusState::Initialized; + // this lets our eose handler know to close unknownids right away + damus + .subscriptions() + .insert("unknownids".to_string(), SubKind::OneShot); + setup_initial_nostrdb_subs(&damus.ndb, &mut damus.note_cache, &mut damus.columns) + .expect("home subscription failed"); + } - damus.state = DamusState::Initialized; - // this lets our eose handler know to close unknownids right away - damus - .subscriptions() - .insert("unknownids".to_string(), SubKind::OneShot); - setup_initial_nostrdb_subs(&damus.ndb, &mut damus.note_cache, &mut damus.columns) - .expect("home subscription failed"); - } + DamusState::NewTimelineSub(new_timeline_id) => { + info!("adding new timeline {}", new_timeline_id); + setup_new_nostrdb_sub( + &damus.ndb, + &mut damus.note_cache, + &mut damus.columns, + new_timeline_id, + ) + .expect("new timeline subscription failed"); + + if let Some(filter) = { + let timeline = damus + .columns + .find_timeline(new_timeline_id) + .expect("timeline"); + match &timeline.filter { + FilterState::Ready(filters) => Some(filters.clone()), + _ => None, + } + } { + let subid = Uuid::new_v4().to_string(); + damus.pool.subscribe(subid, filter); + + damus.state = DamusState::Initialized; + } + } + + DamusState::Initialized => (), + }; if let Err(err) = try_process_event(damus, ctx) { error!("error processing event: {}", err); } + + damus.columns.attempt_perform_deletion_request(); } fn process_event(damus: &mut Damus, _subid: &str, event: &str) { @@ -643,11 +700,7 @@ impl Damus { let debug = parsed_args.debug; if columns.columns().is_empty() { - let filter = Filter::from_json(include_str!("../queries/timeline.json")).unwrap(); - columns.add_timeline(Timeline::new( - TimelineKind::Generic, - FilterState::ready(vec![filter]), - )) + columns.new_column_picker(); } Self { @@ -714,6 +767,10 @@ impl Damus { } } + pub fn subscribe_new_timeline(&mut self, timeline_id: TimelineId) { + self.state = DamusState::NewTimelineSub(timeline_id); + } + pub fn mock<P: AsRef<Path>>(data_path: P) -> Self { let mut columns = Columns::new(); let filter = Filter::from_json(include_str!("../queries/global.json")).unwrap(); @@ -897,7 +954,7 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { puffin::profile_function!(); let screen_size = ctx.screen_rect().width(); - let calc_panel_width = (screen_size / app.columns.columns().len() as f32) - 30.0; + let calc_panel_width = (screen_size / app.columns.num_columns() as f32) - 30.0; let min_width = 320.0; let need_scroll = calc_panel_width < min_width; let panel_sizes = if need_scroll { @@ -910,18 +967,18 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { ui.spacing_mut().item_spacing.x = 0.0; if need_scroll { egui::ScrollArea::horizontal().show(ui, |ui| { - timelines_view(ui, panel_sizes, app, app.columns.columns().len()); + timelines_view(ui, panel_sizes, app); }); } else { - timelines_view(ui, panel_sizes, app, app.columns.columns().len()); + timelines_view(ui, panel_sizes, app); } }); } -fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, columns: usize) { +fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { StripBuilder::new(ui) .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH)) - .sizes(sizes, columns) + .sizes(sizes, app.columns.num_columns()) .clip(true) .horizontal(|mut strip| { strip.cell(|ui| { @@ -933,22 +990,8 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, columns: usiz ) .show(ui); - let router = if let Some(router) = app - .columns - .columns_mut() - .get_mut(0) - .map(|c: &mut Column| c.router_mut()) - { - router - } else { - // TODO(jb55): Maybe we should have an empty column route? - let columns = app.columns.columns_mut(); - columns.push(Column::new(vec![Route::accounts()])); - columns[0].router_mut() - }; - if side_panel.response.clicked() { - DesktopSidePanel::perform_action(router, side_panel.action); + DesktopSidePanel::perform_action(app.columns_mut(), side_panel.action); } // vertical sidebar line @@ -959,11 +1002,10 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, columns: usiz ); }); - let n_cols = app.columns.columns().len(); - for column_ind in 0..n_cols { + for col_index in 0..app.columns.num_columns() { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); - nav::render_nav(column_ind, app, ui); + nav::render_nav(col_index, app, ui); // vertical line ui.painter().vline( diff --git a/src/app_style.rs b/src/app_style.rs @@ -1,5 +1,6 @@ -use crate::colors::{ - desktop_dark_color_theme, light_color_theme, mobile_dark_color_theme, ColorTheme, +use crate::{ + colors::{desktop_dark_color_theme, light_color_theme, mobile_dark_color_theme, ColorTheme}, + ui::is_narrow, }; use egui::{ epaint::Shadow, @@ -96,6 +97,14 @@ pub fn mobile_font_size(text_style: &NotedeckTextStyle) -> f32 { } } +pub fn get_font_size(ctx: &egui::Context, text_style: &NotedeckTextStyle) -> f32 { + if is_narrow(ctx) { + mobile_font_size(text_style) + } else { + desktop_font_size(text_style) + } +} + #[derive(Copy, Clone, Eq, PartialEq, Debug, EnumIter)] pub enum NotedeckTextStyle { Heading, diff --git a/src/column.rs b/src/column.rs @@ -1,6 +1,8 @@ use crate::route::{Route, Router}; use crate::timeline::{Timeline, TimelineId}; +use indexmap::IndexMap; use std::iter::Iterator; +use std::sync::atomic::{AtomicU32, Ordering}; use tracing::warn; pub struct Column { @@ -25,16 +27,18 @@ impl Column { #[derive(Default)] pub struct Columns { /// Columns are simply routers into settings, timelines, etc - columns: Vec<Column>, + columns: IndexMap<u32, Column>, /// Timeline state is not tied to routing logic separately, so that /// different columns can navigate to and from settings to timelines, /// etc. - pub timelines: Vec<Timeline>, + pub timelines: IndexMap<u32, Timeline>, /// The selected column for key navigation selected: i32, + should_delete_column_at_index: Option<usize>, } +static UIDS: AtomicU32 = AtomicU32::new(0); impl Columns { pub fn new() -> Self { @@ -42,49 +46,112 @@ impl Columns { } pub fn add_timeline(&mut self, timeline: Timeline) { + let id = Self::get_new_id(); let routes = vec![Route::timeline(timeline.id)]; - self.timelines.push(timeline); - self.columns.push(Column::new(routes)) + self.timelines.insert(id, timeline); + self.columns.insert(id, Column::new(routes)); } - pub fn columns_mut(&mut self) -> &mut Vec<Column> { - &mut self.columns + pub fn add_timeline_to_column(&mut self, col: usize, timeline: Timeline) { + let col_id = self.get_column_id_at_index(col); + self.column_mut(col) + .router_mut() + .route_to_replaced(Route::timeline(timeline.id)); + self.timelines.insert(col_id, timeline); + } + + pub fn new_column_picker(&mut self) { + self.add_column(Column::new(vec![Route::AddColumn])); + } + + fn get_new_id() -> u32 { + UIDS.fetch_add(1, Ordering::Relaxed) + } + + pub fn add_column(&mut self, column: Column) { + self.columns.insert(Self::get_new_id(), column); + } + + pub fn columns_mut(&mut self) -> Vec<&mut Column> { + self.columns.values_mut().collect() + } + + pub fn num_columns(&self) -> usize { + self.columns.len() + } + + // Get the first router in the columns if there are columns present. + // Otherwise, create a new column picker and return the router + pub fn get_first_router(&mut self) -> &mut Router<Route> { + if self.columns.is_empty() { + self.new_column_picker(); + } + self.columns + .get_index_mut(0) + .expect("There should be at least one column") + .1 + .router_mut() } pub fn timeline_mut(&mut self, timeline_ind: usize) -> &mut Timeline { - &mut self.timelines[timeline_ind] + self.timelines + .get_index_mut(timeline_ind) + .expect("expected index to be in bounds") + .1 } pub fn column(&self, ind: usize) -> &Column { - &self.columns()[ind] + self.columns + .get_index(ind) + .expect("Expected index to be in bounds") + .1 + } + + pub fn columns(&self) -> Vec<&Column> { + self.columns.values().collect() } - pub fn columns(&self) -> &Vec<Column> { - &self.columns + pub fn get_column_id_at_index(&self, ind: usize) -> u32 { + *self + .columns + .get_index(ind) + .expect("expected index to be within bounds") + .0 } pub fn selected(&mut self) -> &mut Column { - &mut self.columns[self.selected as usize] + self.columns + .get_index_mut(self.selected as usize) + .expect("Expected selected index to be in bounds") + .1 } - pub fn timelines_mut(&mut self) -> &mut Vec<Timeline> { - &mut self.timelines + pub fn timelines_mut(&mut self) -> Vec<&mut Timeline> { + self.timelines.values_mut().collect() } - pub fn timelines(&self) -> &Vec<Timeline> { - &self.timelines + pub fn timelines(&self) -> Vec<&Timeline> { + self.timelines.values().collect() } pub fn find_timeline_mut(&mut self, id: TimelineId) -> Option<&mut Timeline> { - self.timelines_mut().iter_mut().find(|tl| tl.id == id) + self.timelines_mut().into_iter().find(|tl| tl.id == id) } pub fn find_timeline(&self, id: TimelineId) -> Option<&Timeline> { - self.timelines().iter().find(|tl| tl.id == id) + self.timelines().into_iter().find(|tl| tl.id == id) } pub fn column_mut(&mut self, ind: usize) -> &mut Column { - &mut self.columns[ind] + self.columns + .get_index_mut(ind) + .expect("Expected index to be in bounds") + .1 + } + + pub fn find_timeline_for_column_index(&self, ind: usize) -> Option<&Timeline> { + let col_id = self.get_column_id_at_index(ind); + self.timelines.get(&col_id) } pub fn select_down(&mut self) { @@ -108,4 +175,23 @@ impl Columns { } self.selected += 1; } + + pub fn request_deletion_at_index(&mut self, index: usize) { + self.should_delete_column_at_index = Some(index); + } + + pub fn attempt_perform_deletion_request(&mut self) { + if let Some(index) = self.should_delete_column_at_index { + if let Some((key, _)) = self.columns.get_index_mut(index) { + self.timelines.shift_remove(key); + } + + self.columns.shift_remove_index(index); + self.should_delete_column_at_index = None; + + if self.columns.is_empty() { + self.new_column_picker(); + } + } + } } diff --git a/src/fonts.rs b/src/fonts.rs @@ -4,12 +4,13 @@ use tracing::debug; pub enum NamedFontFamily { Medium, + Bold, } impl NamedFontFamily { pub fn as_str(&mut self) -> &'static str { match self { - //Self::Bold => "bold", + Self::Bold => "bold", Self::Medium => "medium", } } @@ -43,7 +44,7 @@ pub fn setup_fonts(ctx: &egui::Context) { "DejaVuSans".to_owned(), FontData::from_static(include_bytes!("../assets/fonts/DejaVuSansSansEmoji.ttf")), ); - /* + font_data.insert( "OnestBold".to_owned(), FontData::from_static(include_bytes!( @@ -51,6 +52,7 @@ pub fn setup_fonts(ctx: &egui::Context) { )), ); + /* font_data.insert( "DejaVuSansBold".to_owned(), FontData::from_static(include_bytes!( @@ -119,7 +121,10 @@ pub fn setup_fonts(ctx: &egui::Context) { medium.extend(base_fonts.clone()); let mut mono = vec!["Inconsolata".to_owned()]; - mono.extend(base_fonts); + mono.extend(base_fonts.clone()); + + let mut bold = vec!["OnestBold".to_owned()]; + bold.extend(base_fonts); families.insert(egui::FontFamily::Proportional, proportional); families.insert(egui::FontFamily::Monospace, mono); @@ -127,6 +132,10 @@ pub fn setup_fonts(ctx: &egui::Context) { egui::FontFamily::Name(NamedFontFamily::Medium.as_str().into()), medium, ); + families.insert( + egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), + bold, + ); debug!("fonts: {:?}", families); diff --git a/src/nav.rs b/src/nav.rs @@ -1,84 +1,121 @@ use crate::{ account_manager::render_accounts_route, + app_style::{get_font_size, NotedeckTextStyle}, + fonts::NamedFontFamily, relay_pool_manager::RelayPoolManager, route::Route, thread::thread_unsubscribe, - timeline::route::{render_timeline_route, TimelineRoute, TimelineRouteResponse}, - ui::{self, note::PostAction, RelayView, View}, + timeline::route::{render_timeline_route, AfterRouteExecution, TimelineRoute}, + ui::{ + self, + add_column::{AddColumnResponse, AddColumnView}, + anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, + note::PostAction, + RelayView, View, + }, Damus, }; -use egui_nav::{Nav, NavAction}; +use egui::{pos2, Color32, InnerResponse, Stroke}; +use egui_nav::{Nav, NavAction, TitleBarResponse}; +use tracing::{error, info}; pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { + let col_id = app.columns.get_column_id_at_index(col); // TODO(jb55): clean up this router_mut mess by using Router<R> in egui-nav directly - let nav_response = Nav::new(app.columns().column(col).router().routes().clone()) + let routes = app + .columns() + .column(col) + .router() + .routes() + .iter() + .map(|r| r.get_titled_route(&app.columns, &app.ndb)) + .collect(); + let nav_response = Nav::new(routes) .navigating(app.columns_mut().column_mut(col).router_mut().navigating) .returning(app.columns_mut().column_mut(col).router_mut().returning) - .title(false) - .show_mut(ui, |ui, nav| match nav.top() { - Route::Timeline(tlr) => render_timeline_route( - &app.ndb, - &mut app.columns, - &mut app.pool, - &mut app.drafts, - &mut app.img_cache, - &mut app.note_cache, - &mut app.threads, - &mut app.accounts, - *tlr, - col, - app.textmode, - ui, - ), - Route::Accounts(amr) => { - render_accounts_route( - ui, + .title(48.0, title_bar) + .show_mut(col_id, ui, |ui, nav| { + let column = app.columns.column_mut(col); + match &nav.top().route { + Route::Timeline(tlr) => render_timeline_route( &app.ndb, - col, &mut app.columns, + &mut app.pool, + &mut app.drafts, &mut app.img_cache, + &mut app.note_cache, + &mut app.threads, &mut app.accounts, - &mut app.view_state.login, - *amr, - ); - None - } - Route::Relays => { - let manager = RelayPoolManager::new(app.pool_mut()); - RelayView::new(manager).ui(ui); - None - } - Route::ComposeNote => { - let kp = app.accounts.selected_or_first_nsec()?; - let draft = app.drafts.compose_mut(); + *tlr, + col, + app.textmode, + ui, + ), + Route::Accounts(amr) => { + render_accounts_route( + ui, + &app.ndb, + col, + &mut app.columns, + &mut app.img_cache, + &mut app.accounts, + &mut app.view_state.login, + *amr, + ); + None + } + Route::Relays => { + let manager = RelayPoolManager::new(app.pool_mut()); + RelayView::new(manager).ui(ui); + None + } + Route::ComposeNote => { + let kp = app.accounts.selected_or_first_nsec()?; + let draft = app.drafts.compose_mut(); - let txn = nostrdb::Transaction::new(&app.ndb).expect("txn"); - let post_response = ui::PostView::new( - &app.ndb, - draft, - crate::draft::DraftSource::Compose, - &mut app.img_cache, - &mut app.note_cache, - kp, - ) - .ui(&txn, ui); - - if let Some(action) = post_response.action { - PostAction::execute(kp, &action, &mut app.pool, draft, |np, seckey| { - np.to_note(seckey) - }); - app.columns_mut().column_mut(col).router_mut().go_back(); + let txn = nostrdb::Transaction::new(&app.ndb).expect("txn"); + let post_response = ui::PostView::new( + &app.ndb, + draft, + crate::draft::DraftSource::Compose, + &mut app.img_cache, + &mut app.note_cache, + kp, + ) + .ui(&txn, ui); + + if let Some(action) = post_response.action { + PostAction::execute(kp, &action, &mut app.pool, draft, |np, seckey| { + np.to_note(seckey) + }); + column.router_mut().go_back(); + } + + None } + Route::AddColumn => { + let resp = + AddColumnView::new(&app.ndb, app.accounts.get_selected_account()).ui(ui); - None + if let Some(resp) = resp { + match resp { + AddColumnResponse::Timeline(timeline) => { + let id = timeline.id; + app.columns_mut().add_timeline_to_column(col, timeline); + app.subscribe_new_timeline(id); + } + }; + } + None + } } }); - if let Some(reply_response) = nav_response.inner { + if let Some(after_route_execution) = nav_response.inner { // start returning when we're finished posting - match reply_response { - TimelineRouteResponse::Post(resp) => { + match after_route_execution { + AfterRouteExecution::Post(resp) => { if let Some(action) = resp.action { match action { PostAction::Post(_) => { @@ -102,6 +139,197 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { ); } } else if let Some(NavAction::Navigated) = nav_response.action { - app.columns_mut().column_mut(col).router_mut().navigating = false; + let cur_router = app.columns_mut().column_mut(col).router_mut(); + cur_router.navigating = false; + if cur_router.is_replacing() { + cur_router.remove_previous_route(); + } + } + + if let Some(title_response) = nav_response.title_response { + match title_response { + TitleResponse::RemoveColumn => { + app.columns_mut().request_deletion_at_index(col); + let tl = app.columns().find_timeline_for_column_index(col); + if let Some(timeline) = tl { + if let Some(sub_id) = timeline.subscription { + if let Err(e) = app.ndb.unsubscribe(sub_id) { + error!("unsubscribe error: {}", e); + } else { + info!( + "successfully unsubscribed from timeline {} with sub id {}", + timeline.id, + sub_id.id() + ); + } + } + } + } + } } } + +fn title_bar( + ui: &mut egui::Ui, + allocated_response: egui::Response, + title_name: String, + back_name: Option<String>, +) -> egui::InnerResponse<TitleBarResponse<TitleResponse>> { + let icon_width = 32.0; + let padding_external = 16.0; + let padding_internal = 8.0; + let has_back = back_name.is_some(); + + let (spacing_rect, titlebar_rect) = allocated_response + .rect + .split_left_right_at_x(allocated_response.rect.left() + padding_external); + ui.advance_cursor_after_rect(spacing_rect); + + let (titlebar_resp, maybe_button_resp) = if has_back { + let (button_rect, titlebar_rect) = titlebar_rect + .split_left_right_at_x(allocated_response.rect.left() + icon_width + padding_external); + ( + allocated_response.with_new_rect(titlebar_rect), + Some(back_button(ui, button_rect)), + ) + } else { + (allocated_response, None) + }; + + title( + ui, + title_name, + titlebar_resp.rect, + icon_width, + if has_back { + padding_internal + } else { + padding_external + }, + ); + + let delete_button_resp = delete_column_button(ui, titlebar_resp, icon_width, padding_external); + let title_response = if delete_button_resp.clicked() { + Some(TitleResponse::RemoveColumn) + } else { + None + }; + + let titlebar_resp = TitleBarResponse { + title_response, + go_back: maybe_button_resp.map_or(false, |r| r.clicked()), + }; + + InnerResponse::new(titlebar_resp, delete_button_resp) +} + +fn back_button(ui: &mut egui::Ui, button_rect: egui::Rect) -> egui::Response { + let horizontal_length = 10.0; + let arrow_length = 5.0; + + let helper = AnimationHelper::new_from_rect(ui, "note-compose-button", button_rect); + let painter = ui.painter_at(helper.get_animation_rect()); + let stroke = Stroke::new(1.5, ui.visuals().text_color()); + + // Horizontal segment + let left_horizontal_point = pos2(-horizontal_length / 2., 0.); + let right_horizontal_point = pos2(horizontal_length / 2., 0.); + let scaled_left_horizontal_point = helper.scale_pos_from_center(left_horizontal_point); + let scaled_right_horizontal_point = helper.scale_pos_from_center(right_horizontal_point); + + painter.line_segment( + [scaled_left_horizontal_point, scaled_right_horizontal_point], + stroke, + ); + + // Top Arrow + let sqrt_2_over_2 = std::f32::consts::SQRT_2 / 2.; + let right_top_arrow_point = helper.scale_pos_from_center(pos2( + left_horizontal_point.x + (sqrt_2_over_2 * arrow_length), + right_horizontal_point.y + sqrt_2_over_2 * arrow_length, + )); + + let scaled_left_arrow_point = scaled_left_horizontal_point; + painter.line_segment([scaled_left_arrow_point, right_top_arrow_point], stroke); + + let right_bottom_arrow_point = helper.scale_pos_from_center(pos2( + left_horizontal_point.x + (sqrt_2_over_2 * arrow_length), + right_horizontal_point.y - sqrt_2_over_2 * arrow_length, + )); + + painter.line_segment([scaled_left_arrow_point, right_bottom_arrow_point], stroke); + + helper.take_animation_response() +} + +fn delete_column_button( + ui: &mut egui::Ui, + allocation_response: egui::Response, + icon_width: f32, + padding: f32, +) -> egui::Response { + let img_size = 16.0; + let max_size = icon_width * ICON_EXPANSION_MULTIPLE; + + let img_data = egui::include_image!("../assets/icons/column_delete_icon_4x.png"); + let img = egui::Image::new(img_data).max_width(img_size); + + let button_rect = { + let titlebar_rect = allocation_response.rect; + let titlebar_width = titlebar_rect.width(); + let titlebar_center = titlebar_rect.center(); + let button_center_y = titlebar_center.y; + let button_center_x = + titlebar_center.x + (titlebar_width / 2.0) - (max_size / 2.0) - padding; + egui::Rect::from_center_size( + pos2(button_center_x, button_center_y), + egui::vec2(max_size, max_size), + ) + }; + + let helper = AnimationHelper::new_from_rect(ui, "delete-column-button", button_rect); + + let cur_img_size = helper.scale_1d_pos(img_size); + + let animation_rect = helper.get_animation_rect(); + let animation_resp = helper.take_animation_response(); + if allocation_response.union(animation_resp.clone()).hovered() { + img.paint_at(ui, animation_rect.shrink((max_size - cur_img_size) / 2.0)); + } + + animation_resp +} + +fn title( + ui: &mut egui::Ui, + title_name: String, + titlebar_rect: egui::Rect, + icon_width: f32, + padding: f32, +) { + let painter = ui.painter_at(titlebar_rect); + + let font = egui::FontId::new( + get_font_size(ui.ctx(), &NotedeckTextStyle::Body), + egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), + ); + + let max_title_width = titlebar_rect.width() - icon_width - padding * 2.; + let title_galley = + ui.fonts(|f| f.layout(title_name, font, ui.visuals().text_color(), max_title_width)); + + let pos = { + let titlebar_center = titlebar_rect.center(); + let text_height = title_galley.rect.height(); + + let galley_pos_x = titlebar_rect.left() + padding; + let galley_pos_y = titlebar_center.y - (text_height / 2.); + pos2(galley_pos_x, galley_pos_y) + }; + + painter.galley(pos, title_galley, Color32::WHITE); +} + +enum TitleResponse { + RemoveColumn, +} diff --git a/src/route.rs b/src/route.rs @@ -1,9 +1,12 @@ use enostr::NoteId; +use nostrdb::Ndb; use std::fmt::{self}; use crate::{ account_manager::AccountsRoute, + column::Columns, timeline::{TimelineId, TimelineRoute}, + ui::profile::preview::get_note_users_displayname_string, }; /// App routing. These describe different places you can go inside Notedeck. @@ -13,6 +16,19 @@ pub enum Route { Accounts(AccountsRoute), Relays, ComposeNote, + AddColumn, +} + +#[derive(Clone)] +pub struct TitledRoute { + pub route: Route, + pub title: String, +} + +impl fmt::Display for TitledRoute { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.title) + } } impl Route { @@ -51,6 +67,42 @@ impl Route { pub fn add_account() -> Self { Route::Accounts(AccountsRoute::AddAccount) } + + pub fn get_titled_route(&self, columns: &Columns, ndb: &Ndb) -> TitledRoute { + let title = match self { + Route::Timeline(tlr) => match tlr { + TimelineRoute::Timeline(id) => { + let timeline = columns + .find_timeline(*id) + .expect("expected to find timeline"); + timeline.kind.to_title(ndb) + } + TimelineRoute::Thread(id) => { + format!("{}'s Thread", get_note_users_displayname_string(ndb, id)) + } + TimelineRoute::Reply(id) => { + format!("{}'s Reply", get_note_users_displayname_string(ndb, id)) + } + TimelineRoute::Quote(id) => { + format!("{}'s Quote", get_note_users_displayname_string(ndb, id)) + } + }, + + Route::Relays => "Relays".to_owned(), + + Route::Accounts(amr) => match amr { + AccountsRoute::Accounts => "Accounts".to_owned(), + AccountsRoute::AddAccount => "Add Account".to_owned(), + }, + Route::ComposeNote => "Compose Note".to_owned(), + Route::AddColumn => "Add Column".to_owned(), + }; + + TitledRoute { + title, + route: *self, + } + } } // TODO: add this to egui-nav so we don't have to deal with returning @@ -60,6 +112,7 @@ pub struct Router<R: Clone> { routes: Vec<R>, pub returning: bool, pub navigating: bool, + replacing: bool, } impl<R: Clone> Router<R> { @@ -69,10 +122,12 @@ impl<R: Clone> Router<R> { } let returning = false; let navigating = false; + let replacing = false; Router { routes, returning, navigating, + replacing, } } @@ -81,6 +136,13 @@ impl<R: Clone> Router<R> { self.routes.push(route); } + // Route to R. Then when it is successfully placed, should call `remove_previous_route` + pub fn route_to_replaced(&mut self, route: R) { + self.navigating = true; + self.replacing = true; + self.routes.push(route); + } + /// Go back, start the returning process pub fn go_back(&mut self) -> Option<R> { if self.returning || self.routes.len() == 1 { @@ -99,6 +161,20 @@ impl<R: Clone> Router<R> { self.routes.pop() } + pub fn remove_previous_route(&mut self) -> Option<R> { + let num_routes = self.routes.len(); + if num_routes <= 1 { + return None; + } + self.returning = false; + self.replacing = false; + Some(self.routes.remove(num_routes - 2)) + } + + pub fn is_replacing(&self) -> bool { + self.replacing + } + pub fn top(&self) -> &R { self.routes.last().expect("routes can't be empty") } @@ -125,6 +201,8 @@ impl fmt::Display for Route { AccountsRoute::AddAccount => write!(f, "Add Account"), }, Route::ComposeNote => write!(f, "Compose Note"), + + Route::AddColumn => write!(f, "Add Column"), } } } diff --git a/src/timeline/kind.rs b/src/timeline/kind.rs @@ -2,6 +2,7 @@ use crate::error::{Error, FilterError}; use crate::filter; use crate::filter::FilterState; use crate::timeline::Timeline; +use crate::ui::profile::preview::get_profile_displayname_string; use enostr::{Filter, Pubkey}; use nostrdb::{Ndb, Transaction}; use std::fmt::Display; @@ -136,7 +137,7 @@ impl TimelineKind { )); } - match Timeline::contact_list(&results[0].note) { + match Timeline::contact_list(&results[0].note, pk_src.clone()) { Err(Error::Filter(FilterError::EmptyContactList)) => Some(Timeline::new( TimelineKind::contact_list(pk_src), FilterState::needs_remote(vec![contact_filter]), @@ -150,4 +151,32 @@ impl TimelineKind { } } } + + pub fn to_title(&self, ndb: &Ndb) -> String { + match self { + TimelineKind::List(list_kind) => match list_kind { + ListKind::Contact(pubkey_source) => match pubkey_source { + PubkeySource::Explicit(pubkey) => { + format!("{}'s Contacts", get_profile_displayname_string(ndb, pubkey)) + } + PubkeySource::DeckAuthor => "Contacts".to_owned(), + }, + }, + TimelineKind::Notifications(pubkey_source) => match pubkey_source { + PubkeySource::DeckAuthor => "Notifications".to_owned(), + PubkeySource::Explicit(pk) => format!( + "{}'s Notifications", + get_profile_displayname_string(ndb, pk) + ), + }, + TimelineKind::Profile(pubkey_source) => match pubkey_source { + PubkeySource::DeckAuthor => "Profile".to_owned(), + PubkeySource::Explicit(pk) => { + format!("{}'s Profile", get_profile_displayname_string(ndb, pk)) + } + }, + TimelineKind::Universe => "Universe".to_owned(), + TimelineKind::Generic => "Custom Filter".to_owned(), + } + } } diff --git a/src/timeline/mod.rs b/src/timeline/mod.rs @@ -8,7 +8,6 @@ use std::fmt; use std::sync::atomic::{AtomicU32, Ordering}; use egui_virtual_list::VirtualList; -use enostr::Pubkey; use nostrdb::{Ndb, Note, Subscription, Transaction}; use std::cell::RefCell; use std::hash::Hash; @@ -180,9 +179,8 @@ pub struct Timeline { impl Timeline { /// Create a timeline from a contact list - pub fn contact_list(contact_list: &Note) -> Result<Self> { + pub fn contact_list(contact_list: &Note, pk_src: PubkeySource) -> Result<Self> { let filter = filter::filter_from_tags(contact_list)?.into_follow_filter(); - let pk_src = PubkeySource::Explicit(Pubkey::new(*contact_list.pubkey())); Ok(Timeline::new( TimelineKind::contact_list(pk_src), @@ -241,13 +239,15 @@ impl Timeline { pub fn poll_notes_into_view( timeline_idx: usize, - timelines: &mut [Timeline], + mut timelines: Vec<&mut Timeline>, ndb: &Ndb, txn: &Transaction, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, ) -> Result<()> { - let timeline = &mut timelines[timeline_idx]; + let timeline = timelines + .get_mut(timeline_idx) + .ok_or(Error::TimelineNotFound)?; let sub = timeline.subscription.ok_or(Error::no_active_sub())?; let new_note_ids = ndb.poll_for_notes(sub, 500); diff --git a/src/timeline/route.rs b/src/timeline/route.rs @@ -26,13 +26,13 @@ pub enum TimelineRoute { Quote(NoteId), } -pub enum TimelineRouteResponse { +pub enum AfterRouteExecution { Post(PostResponse), } -impl TimelineRouteResponse { +impl AfterRouteExecution { pub fn post(post: PostResponse) -> Self { - TimelineRouteResponse::Post(post) + AfterRouteExecution::Post(post) } } @@ -50,7 +50,7 @@ pub fn render_timeline_route( col: usize, textmode: bool, ui: &mut egui::Ui, -) -> Option<TimelineRouteResponse> { +) -> Option<AfterRouteExecution> { match route { TimelineRoute::Timeline(timeline_id) => { if let Some(bar_action) = @@ -58,7 +58,8 @@ pub fn render_timeline_route( .ui(ui) { let txn = Transaction::new(ndb).expect("txn"); - let router = columns.columns_mut()[col].router_mut(); + let mut cur_column = columns.columns_mut(); + let router = cur_column[col].router_mut(); bar_action.execute_and_process_result(ndb, router, threads, note_cache, pool, &txn); } @@ -73,7 +74,8 @@ pub fn render_timeline_route( .ui(ui) { let txn = Transaction::new(ndb).expect("txn"); - let router = columns.columns_mut()[col].router_mut(); + let mut cur_column = columns.columns_mut(); + let router = cur_column[col].router_mut(); bar_action.execute_and_process_result(ndb, router, threads, note_cache, pool, &txn); } @@ -111,7 +113,7 @@ pub fn render_timeline_route( }); } - Some(TimelineRouteResponse::post(response.inner)) + Some(AfterRouteExecution::post(response.inner)) } TimelineRoute::Quote(id) => { @@ -140,7 +142,7 @@ pub fn render_timeline_route( np.to_quote(seckey, &note) }); } - Some(TimelineRouteResponse::post(response.inner)) + Some(AfterRouteExecution::post(response.inner)) } } } diff --git a/src/ui/add_column.rs b/src/ui/add_column.rs @@ -0,0 +1,246 @@ +use egui::{pos2, vec2, Color32, FontId, ImageSource, Pos2, Rect, Separator, Ui}; +use nostrdb::Ndb; + +use crate::{ + app_style::{get_font_size, NotedeckTextStyle}, + timeline::{PubkeySource, Timeline, TimelineKind}, + ui::anim::ICON_EXPANSION_MULTIPLE, + user_account::UserAccount, +}; + +use super::anim::AnimationHelper; + +pub enum AddColumnResponse { + Timeline(Timeline), +} + +#[derive(Clone, Debug)] +enum AddColumnOption { + Universe, + Notification(PubkeySource), + Home(PubkeySource), +} + +impl AddColumnOption { + pub fn take_as_response( + self, + ndb: &Ndb, + cur_account: Option<&UserAccount>, + ) -> Option<AddColumnResponse> { + match self { + AddColumnOption::Universe => TimelineKind::Universe + .into_timeline(ndb, None) + .map(AddColumnResponse::Timeline), + AddColumnOption::Notification(pubkey) => TimelineKind::Notifications(pubkey) + .into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) + .map(AddColumnResponse::Timeline), + AddColumnOption::Home(pubkey) => { + let tlk = TimelineKind::contact_list(pubkey); + tlk.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) + .map(AddColumnResponse::Timeline) + } + } + } +} + +pub struct AddColumnView<'a> { + ndb: &'a Ndb, + cur_account: Option<&'a UserAccount>, +} + +impl<'a> AddColumnView<'a> { + pub fn new(ndb: &'a Ndb, cur_account: Option<&'a UserAccount>) -> Self { + Self { ndb, cur_account } + } + + pub fn ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { + let mut selected_option: Option<AddColumnResponse> = None; + for column_option_data in self.get_column_options() { + let option = column_option_data.option.clone(); + if self.column_option_ui(ui, column_option_data).clicked() { + selected_option = option.take_as_response(self.ndb, self.cur_account); + } + + ui.add(Separator::default().spacing(0.0)); + } + + selected_option + } + + fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response { + let icon_padding = 8.0; + let min_icon_width = 32.0; + let height_padding = 12.0; + let max_width = ui.available_width(); + let title_style = NotedeckTextStyle::Body; + let desc_style = NotedeckTextStyle::Button; + let title_min_font_size = get_font_size(ui.ctx(), &title_style); + let desc_min_font_size = get_font_size(ui.ctx(), &desc_style); + + let max_height = { + let max_wrap_width = + max_width - ((icon_padding * 2.0) + (min_icon_width * ICON_EXPANSION_MULTIPLE)); + let title_max_font = FontId::new( + title_min_font_size * ICON_EXPANSION_MULTIPLE, + title_style.font_family(), + ); + let desc_max_font = FontId::new( + desc_min_font_size * ICON_EXPANSION_MULTIPLE, + desc_style.font_family(), + ); + let max_desc_galley = ui.fonts(|f| { + f.layout( + data.description.to_string(), + desc_max_font, + Color32::WHITE, + max_wrap_width, + ) + }); + + let max_title_galley = ui.fonts(|f| { + f.layout( + data.title.to_string(), + title_max_font, + Color32::WHITE, + max_wrap_width, + ) + }); + + let desc_font_max_size = max_desc_galley.rect.height(); + let title_font_max_size = max_title_galley.rect.height(); + title_font_max_size + desc_font_max_size + (2.0 * height_padding) + }; + + let helper = AnimationHelper::new(ui, data.title, vec2(max_width, max_height)); + let animation_rect = helper.get_animation_rect(); + + let cur_icon_width = helper.scale_1d_pos(min_icon_width); + let painter = ui.painter_at(animation_rect); + + let cur_icon_size = vec2(cur_icon_width, cur_icon_width); + let cur_icon_x_pos = animation_rect.left() + (icon_padding) + (cur_icon_width / 2.0); + + let title_cur_font = FontId::new( + helper.scale_1d_pos(title_min_font_size), + title_style.font_family(), + ); + + let desc_cur_font = FontId::new( + helper.scale_1d_pos(desc_min_font_size), + desc_style.font_family(), + ); + + let wrap_width = max_width - (cur_icon_width + (icon_padding * 2.0)); + let text_color = ui.ctx().style().visuals.text_color(); + let fallback_color = ui.ctx().style().visuals.weak_text_color(); + + let title_galley = painter.layout( + data.title.to_string(), + title_cur_font, + text_color, + wrap_width, + ); + let desc_galley = painter.layout( + data.description.to_string(), + desc_cur_font, + text_color, + wrap_width, + ); + + let galley_heights = title_galley.rect.height() + desc_galley.rect.height(); + + let cur_height_padding = (animation_rect.height() - galley_heights) / 2.0; + let corner_x_pos = cur_icon_x_pos + (cur_icon_width / 2.0) + icon_padding; + let title_corner_pos = Pos2::new(corner_x_pos, animation_rect.top() + cur_height_padding); + let desc_corner_pos = Pos2::new( + corner_x_pos, + title_corner_pos.y + title_galley.rect.height(), + ); + + let icon_cur_y = animation_rect.top() + cur_height_padding + (galley_heights / 2.0); + let icon_img = egui::Image::new(data.icon).fit_to_exact_size(cur_icon_size); + let icon_rect = Rect::from_center_size(pos2(cur_icon_x_pos, icon_cur_y), cur_icon_size); + + icon_img.paint_at(ui, icon_rect); + painter.galley(title_corner_pos, title_galley, fallback_color); + painter.galley(desc_corner_pos, desc_galley, fallback_color); + + helper.take_animation_response() + } + + fn get_column_options(&self) -> Vec<ColumnOptionData> { + let mut vec = Vec::new(); + vec.push(ColumnOptionData { + title: "Universe", + description: "See the whole nostr universe", + icon: egui::include_image!("../../assets/icons/universe_icon_dark_4x.png"), + option: AddColumnOption::Universe, + }); + + if let Some(acc) = self.cur_account { + let source = if acc.secret_key.is_some() { + PubkeySource::DeckAuthor + } else { + PubkeySource::Explicit(acc.pubkey) + }; + + vec.push(ColumnOptionData { + title: "Home timeline", + description: "See recommended notes first", + icon: egui::include_image!("../../assets/icons/home_icon_dark_4x.png"), + option: AddColumnOption::Home(source.clone()), + }); + vec.push(ColumnOptionData { + title: "Notifications", + description: "Stay up to date with notifications and mentions", + icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"), + option: AddColumnOption::Notification(source), + }); + } + + vec + } +} + +struct ColumnOptionData { + title: &'static str, + description: &'static str, + icon: ImageSource<'static>, + option: AddColumnOption, +} + +mod preview { + use crate::{ + test_data, + ui::{Preview, PreviewConfig, View}, + Damus, + }; + + use super::AddColumnView; + + pub struct AddColumnPreview { + app: Damus, + } + + impl AddColumnPreview { + fn new() -> Self { + let app = test_data::test_app(); + + AddColumnPreview { app } + } + } + + impl View for AddColumnPreview { + fn ui(&mut self, ui: &mut egui::Ui) { + AddColumnView::new(&self.app.ndb, self.app.accounts.get_selected_account()).ui(ui); + } + } + + impl<'a> Preview for AddColumnView<'a> { + type Prev = AddColumnPreview; + + fn preview(_cfg: PreviewConfig) -> Self::Prev { + AddColumnPreview::new() + } + } +} diff --git a/src/ui/anim.rs b/src/ui/anim.rs @@ -60,6 +60,27 @@ impl AnimationHelper { } } + pub fn new_from_rect( + ui: &mut egui::Ui, + animation_name: impl std::hash::Hash, + animation_rect: egui::Rect, + ) -> Self { + let id = ui.id().with(animation_name); + let response = ui.allocate_rect(animation_rect, Sense::click()); + + let animation_progress = + ui.ctx() + .animate_bool_with_time(id, response.hovered(), ANIM_SPEED); + + Self { + rect: animation_rect, + center: animation_rect.center(), + response, + animation_progress, + expansion_multiple: ICON_EXPANSION_MULTIPLE, + } + } + pub fn scale_1d_pos(&self, min_object_size: f32) -> f32 { let max_object_size = min_object_size * self.expansion_multiple; @@ -93,4 +114,8 @@ impl AnimationHelper { self.center.y + self.scale_1d_pos(y_min), ) } + + pub fn scale_pos_from_center(&self, min_pos: Pos2) -> Pos2 { + self.scale_from_center(min_pos.x, min_pos.y) + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs @@ -1,5 +1,6 @@ pub mod account_login_view; pub mod account_management; +pub mod add_column; pub mod anim; pub mod mention; pub mod note; diff --git a/src/ui/profile/preview.rs b/src/ui/profile/preview.rs @@ -6,6 +6,7 @@ use crate::{colors, images, DisplayName}; use egui::load::TexturePoll; use egui::{Frame, RichText, Sense, Widget}; use egui_extras::Size; +use enostr::NoteId; use nostrdb::ProfileRecord; pub struct ProfilePreview<'a, 'cache> { @@ -256,3 +257,28 @@ fn about_section_widget<'a>(profile: &'a ProfileRecord<'a>) -> impl egui::Widget } } } + +fn get_display_name_as_string(profile: Option<&'_ ProfileRecord<'_>>) -> String { + let display_name = get_display_name(profile); + match display_name { + DisplayName::One(n) => n.to_string(), + DisplayName::Both { display_name, .. } => display_name.to_string(), + } +} + +pub fn get_profile_displayname_string(ndb: &nostrdb::Ndb, pk: &enostr::Pubkey) -> String { + let txn = nostrdb::Transaction::new(ndb).expect("Transaction should have worked"); + let profile = ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok(); + get_display_name_as_string(profile.as_ref()) +} + +pub fn get_note_users_displayname_string(ndb: &nostrdb::Ndb, id: &NoteId) -> String { + let txn = nostrdb::Transaction::new(ndb).expect("Transaction should have worked"); + let note = ndb.get_note_by_id(&txn, id.bytes()); + let profile = if let Ok(note) = note { + ndb.get_profile_by_pubkey(&txn, note.pubkey()).ok() + } else { + None + }; + get_display_name_as_string(profile.as_ref()) +} diff --git a/src/ui/side_panel.rs b/src/ui/side_panel.rs @@ -4,9 +4,9 @@ use tracing::info; use crate::{ account_manager::AccountsRoute, colors, - column::Column, + column::{Column, Columns}, imgcache::ImageCache, - route::{Route, Router}, + route::Route, user_account::UserAccount, Damus, }; @@ -162,7 +162,8 @@ impl<'a> DesktopSidePanel<'a> { helper.take_animation_response() } - pub fn perform_action(router: &mut Router<Route>, action: SidePanelAction) { + pub fn perform_action(columns: &mut Columns, action: SidePanelAction) { + let router = columns.get_first_router(); match action { SidePanelAction::Panel => {} // TODO SidePanelAction::Account => { @@ -186,8 +187,11 @@ impl<'a> DesktopSidePanel<'a> { } } SidePanelAction::Columns => { - // TODO - info!("Clicked columns button"); + if router.routes().iter().any(|&r| r == Route::AddColumn) { + router.go_back(); + } else { + columns.new_column_picker(); + } } SidePanelAction::ComposeNote => { if router.routes().iter().any(|&r| r == Route::ComposeNote) { @@ -366,9 +370,7 @@ mod preview { impl DesktopSidePanelPreview { fn new() -> Self { let mut app = test_data::test_app(); - app.columns - .columns_mut() - .push(Column::new(vec![Route::accounts()])); + app.columns.add_column(Column::new(vec![Route::accounts()])); DesktopSidePanelPreview { app } } } @@ -388,10 +390,7 @@ mod preview { ); let response = panel.show(ui); - DesktopSidePanel::perform_action( - self.app.columns.columns_mut()[0].router_mut(), - response.action, - ); + DesktopSidePanel::perform_action(&mut self.app.columns, response.action); }); }); } diff --git a/src/ui_preview/main.rs b/src/ui_preview/main.rs @@ -1,6 +1,7 @@ use notedeck::app_creation::{ generate_mobile_emulator_native_options, generate_native_options, setup_cc, }; +use notedeck::ui::add_column::AddColumnView; use notedeck::ui::{ account_login_view::AccountLoginView, account_management::AccountsView, DesktopSidePanel, PostView, Preview, PreviewApp, PreviewConfig, ProfilePic, ProfilePreview, RelayView, @@ -102,5 +103,6 @@ async fn main() { AccountsView, DesktopSidePanel, PostView, + AddColumnView, ); }