From 71259a8dd5da1e5e3fe78db78654ee4b7fc9026c Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 30 Aug 2024 08:44:49 -0700 Subject: [PATCH] timeline: initial contact queries This implements initial local contact queries. For testing you can create contact list columns via: Examples -------- Make a contacts column from a specific npub: $ notedeck --column contacts:npub... Use the current user's contacts: $ notedeck --column contacts --pub npub... We also introduce a new ColumnKind enum which is used to describe the column type. Signed-off-by: William Casarin --- enostr/src/pubkey.rs | 7 +++ src/app.rs | 146 ++++++++++++++++++++++++++++++++++++------- src/timeline.rs | 68 +++++++++++++++++--- 3 files changed, 191 insertions(+), 30 deletions(-) diff --git a/enostr/src/pubkey.rs b/enostr/src/pubkey.rs index 3912429..3941c03 100644 --- a/enostr/src/pubkey.rs +++ b/enostr/src/pubkey.rs @@ -23,6 +23,13 @@ impl Pubkey { &self.0 } + pub fn parse(s: &str) -> Result { + match Pubkey::from_hex(s) { + Ok(pk) => Ok(pk), + Err(_) => Pubkey::try_from_bech32_string(s, false), + } + } + pub fn from_hex(hex_str: &str) -> Result { Ok(Pubkey(hex::decode(hex_str)?.as_slice().try_into()?)) } diff --git a/src/app.rs b/src/app.rs index b29b6c2..86d88ba 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,13 +11,13 @@ use crate::notecache::{CachedNote, NoteCache}; use crate::relay_pool_manager::RelayPoolManager; use crate::route::Route; use crate::thread::{DecrementResult, Threads}; -use crate::timeline::{Timeline, TimelineSource, ViewFilter}; +use crate::timeline::{ColumnKind, ListKind, PubkeySource, Timeline, TimelineSource, ViewFilter}; use crate::ui::note::PostAction; use crate::ui::{self, AccountSelectionWidget, DesktopGlobalPopup}; use crate::ui::{DesktopSidePanel, RelayView, View}; -use crate::Result; +use crate::{Error, Result}; use egui_nav::{Nav, NavAction}; -use enostr::{ClientMessage, Keypair, RelayEvent, RelayMessage, RelayPool, SecretKey}; +use enostr::{ClientMessage, Keypair, Pubkey, RelayEvent, RelayMessage, RelayPool, SecretKey}; use std::cell::RefCell; use std::rc::Rc; @@ -104,7 +104,13 @@ fn send_initial_filters(damus: &mut Damus, relay_url: &str) { let relay = &mut relay.relay; if relay.url == relay_url { for timeline in &damus.timelines { - let filter = timeline.filter.clone(); + let filter = if let Some(filter) = &timeline.filter { + filter.clone() + } else { + // TODO: handle unloaded filters + continue; + }; + let new_filters = filter.into_iter().map(|f| { // limit the size of remote filters let default_limit = crate::filter::default_remote_limit(); @@ -353,7 +359,14 @@ fn setup_profiling() { fn setup_initial_nostrdb_subs(damus: &mut Damus) -> Result<()> { let timelines = damus.timelines.len(); for i in 0..timelines { - let filters = damus.timelines[i].filter.clone(); + let filters = if let Some(filters) = &damus.timelines[i].filter { + filters.clone() + } else { + // TODO: for unloaded filters, we will need to fetch things like + // the contact and relay list from remote relays. + continue; + }; + damus.timelines[i].subscription = Some(damus.ndb.subscribe(filters.clone())?); let txn = Transaction::new(&damus.ndb)?; debug!( @@ -361,13 +374,8 @@ fn setup_initial_nostrdb_subs(damus: &mut Damus) -> Result<()> { damus.timelines[i].subscription.as_ref().unwrap().id, damus.timelines[i].filter ); - let results = damus.ndb.query( - &txn, - filters, - damus.timelines[i].filter[0] - .limit() - .unwrap_or(crate::filter::default_limit()) as i32, - )?; + let lim = filters[0].limit().unwrap_or(crate::filter::default_limit()) as i32; + let results = damus.ndb.query(&txn, filters.clone(), lim)?; let filters = { let views = &damus.timelines[i].views; @@ -527,8 +535,65 @@ fn render_damus(damus: &mut Damus, ctx: &Context) { puffin_egui::profiler_window(ctx); } +enum ArgColumn { + Column(ColumnKind), + Generic(Vec), +} + +impl ArgColumn { + pub fn into_timeline(self, ndb: &Ndb, user: Option<&[u8; 32]>) -> Timeline { + match self { + ArgColumn::Generic(filters) => Timeline::new(ColumnKind::Generic, Some(filters)), + + ArgColumn::Column(ColumnKind::Universe) => { + Timeline::new(ColumnKind::Universe, Some(vec![])) + } + + ArgColumn::Column(ColumnKind::Generic) => { + panic!("Not a valid ArgColumn") + } + + ArgColumn::Column(ColumnKind::List(ListKind::Contact(ref pk_src))) => { + let pk = match pk_src { + PubkeySource::DeckAuthor => { + if let Some(user_pk) = user { + user_pk + } else { + // No user loaded, so we have to return an unloaded + // contact list columns + return Timeline::new( + ColumnKind::contact_list(PubkeySource::DeckAuthor), + None, + ); + } + } + PubkeySource::Explicit(pk) => pk.bytes(), + }; + + let contact_filter = Filter::new().authors([pk]).kinds([3]).limit(1).build(); + let txn = Transaction::new(ndb).expect("txn"); + let results = ndb + .query(&txn, vec![contact_filter], 1) + .expect("contact query failed?"); + + if results.is_empty() { + return Timeline::new(ColumnKind::contact_list(pk_src.to_owned()), None); + } + + match Timeline::contact_list(&results[0].note) { + Err(Error::EmptyContactList) => { + Timeline::new(ColumnKind::contact_list(pk_src.to_owned()), None) + } + Err(e) => panic!("Unexpected error: {e}"), + Ok(tl) => tl, + } + } + } + } +} + struct Args { - timelines: Vec, + columns: Vec, relays: Vec, is_mobile: Option, keys: Vec, @@ -540,7 +605,7 @@ struct Args { impl Args { fn parse(args: &[String]) -> Self { let mut res = Args { - timelines: vec![], + columns: vec![], relays: vec![], is_mobile: None, keys: vec![], @@ -606,7 +671,7 @@ impl Args { }; if let Ok(filter) = Filter::from_json(filter) { - res.timelines.push(Timeline::new(vec![filter])); + res.columns.push(ArgColumn::Generic(vec![filter])); } else { error!("failed to parse filter '{}'", filter); } @@ -628,6 +693,30 @@ impl Args { continue; }; res.relays.push(relay.clone()); + } else if arg == "--column" || arg == "-c" { + i += 1; + let column_name = if let Some(next_arg) = args.get(i) { + next_arg + } else { + error!("column argument missing"); + continue; + }; + + if column_name.starts_with("contacts:") { + if let Ok(pubkey) = Pubkey::parse(&column_name[9..]) { + info!("got contact column for user {}", pubkey.hex()); + res.columns.push(ArgColumn::Column(ColumnKind::contact_list( + PubkeySource::Explicit(pubkey), + ))) + } else { + error!("error parsing contacts pubkey {}", &column_name[9..]); + continue; + } + } else if column_name == "contacts" { + res.columns.push(ArgColumn::Column(ColumnKind::contact_list( + PubkeySource::DeckAuthor, + ))) + } } else if arg == "--filter-file" || arg == "-f" { i += 1; let filter_file = if let Some(next_arg) = args.get(i) { @@ -648,7 +737,7 @@ impl Args { .ok() .and_then(|s| Filter::from_json(s).ok()) { - res.timelines.push(Timeline::new(vec![filter])); + res.columns.push(ArgColumn::Generic(vec![filter])); } else { error!("failed to parse filter in '{}'", filter_file); } @@ -657,9 +746,10 @@ impl Args { i += 1; } - if res.timelines.is_empty() { - let filter = Filter::from_json(include_str!("../queries/timeline.json")).unwrap(); - res.timelines.push(Timeline::new(vec![filter])); + if res.columns.is_empty() { + let ck = ColumnKind::contact_list(PubkeySource::DeckAuthor); + info!("No columns set, setting up defaults: {:?}", ck); + res.columns.push(ArgColumn::Column(ck)); } res @@ -746,6 +836,17 @@ impl Damus { pool }; + let account = account_manager + .get_selected_account() + .as_ref() + .map(|a| a.pubkey.bytes()); + let ndb = Ndb::new(&dbpath, &config).expect("ndb"); + let timelines = parsed_args + .columns + .into_iter() + .map(|c| c.into_timeline(&ndb, account)) + .collect(); + Self { pool, is_mobile, @@ -756,11 +857,10 @@ impl Damus { img_cache: ImageCache::new(imgcache_dir), note_cache: NoteCache::default(), selected_timeline: 0, - timelines: parsed_args.timelines, + timelines, textmode: false, - ndb: Ndb::new(&dbpath, &config).expect("ndb"), + ndb, account_manager, - //compose: "".to_string(), frame_history: FrameHistory::default(), show_account_switcher: false, show_global_popup: false, @@ -771,7 +871,7 @@ impl Damus { pub fn mock>(data_path: P, is_mobile: bool) -> Self { let mut timelines: Vec = vec![]; let filter = Filter::from_json(include_str!("../queries/global.json")).unwrap(); - timelines.push(Timeline::new(vec![filter])); + timelines.push(Timeline::new(ColumnKind::Universe, Some(vec![filter]))); let imgcache_dir = data_path.as_ref().join(ImageCache::rel_datadir()); let _ = std::fs::create_dir_all(imgcache_dir.clone()); diff --git a/src/timeline.rs b/src/timeline.rs index e793c1f..3930635 100644 --- a/src/timeline.rs +++ b/src/timeline.rs @@ -8,13 +8,58 @@ use crate::{Damus, Result}; use crate::route::Route; use egui_virtual_list::VirtualList; +use enostr::Pubkey; use nostrdb::{Filter, Note, Subscription, Transaction}; use std::cell::RefCell; use std::collections::HashSet; +use std::fmt::Display; use std::rc::Rc; use tracing::{debug, error}; +#[derive(Clone, Debug)] +pub enum PubkeySource { + Explicit(Pubkey), + DeckAuthor, +} + +#[derive(Debug)] +pub enum ListKind { + Contact(PubkeySource), +} + +/// +/// What kind of column is it? +/// - Follow List +/// - Notifications +/// - DM +/// - filter +/// - ... etc +#[derive(Debug)] +pub enum ColumnKind { + List(ListKind), + Universe, + + /// Generic filter + Generic, +} + +impl Display for ColumnKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ColumnKind::List(ListKind::Contact(_src)) => f.write_str("Contacts"), + ColumnKind::Generic => f.write_str("Timeline"), + ColumnKind::Universe => f.write_str("Universe"), + } + } +} + +impl ColumnKind { + pub fn contact_list(pk: PubkeySource) -> Self { + ColumnKind::List(ListKind::Contact(pk)) + } +} + #[derive(Debug, Copy, Clone)] pub enum TimelineSource<'a> { Column { ind: usize }, @@ -273,8 +318,12 @@ impl TimelineTab { } } +/// A column in a deck. Holds navigation state, loaded notes, column kind, etc. pub struct Timeline { - pub filter: Vec, + pub kind: ColumnKind, + // We may not have the filter loaded yet, so let's make it an option so + // that codepaths have to explicitly handle it + pub filter: Option>, pub views: Vec, pub selected_view: i32, pub routes: Vec, @@ -287,23 +336,28 @@ pub struct Timeline { impl Timeline { /// Create a timeline from a contact list - pub fn follows(contact_list: &Note) -> Result { - Ok(Timeline::new(vec![filter::filter_from_tags(contact_list)? - .kinds([1]) - .build()])) + pub fn contact_list(contact_list: &Note) -> Result { + let filter = vec![filter::filter_from_tags(contact_list)?.kinds([1]).build()]; + let pk_src = PubkeySource::Explicit(Pubkey::new(contact_list.pubkey())); + + Ok(Timeline::new( + ColumnKind::contact_list(pk_src), + Some(filter), + )) } - pub fn new(filter: Vec) -> Self { + pub fn new(kind: ColumnKind, filter: Option>) -> Self { let subscription: Option = None; let notes = TimelineTab::new(ViewFilter::Notes); let replies = TimelineTab::new(ViewFilter::NotesAndReplies); let views = vec![notes, replies]; let selected_view = 0; - let routes = vec![Route::Timeline("Timeline".to_string())]; + let routes = vec![Route::Timeline(format!("{}", kind))]; let navigating = false; let returning = false; Timeline { + kind, navigating, returning, filter,