From 662755550f2181b6923c04919296359245f8aa75 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 25 Dec 2024 19:06:04 -0800 Subject: [PATCH] wip algo timelines Signed-off-by: William Casarin --- crates/notedeck_columns/src/route.rs | 6 +- crates/notedeck_columns/src/storage/decks.rs | 456 +++++++++++++------ crates/notedeck_columns/src/timeline/kind.rs | 83 +++- crates/notedeck_columns/src/timeline/mod.rs | 31 +- crates/notedeck_columns/src/ui/add_column.rs | 139 +++++- 5 files changed, 556 insertions(+), 159 deletions(-) diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs index 647aa66b..3f06dc44 100644 --- a/crates/notedeck_columns/src/route.rs +++ b/crates/notedeck_columns/src/route.rs @@ -5,7 +5,7 @@ use crate::{ accounts::AccountsRoute, column::Columns, timeline::{kind::ColumnTitle, TimelineId, TimelineRoute}, - ui::add_column::AddColumnRoute, + ui::add_column::{AddAlgoRoute, AddColumnRoute}, }; /// App routing. These describe different places you can go inside Notedeck. @@ -88,6 +88,10 @@ impl Route { Route::ComposeNote => ColumnTitle::simple("Compose Note"), Route::AddColumn(c) => match c { AddColumnRoute::Base => ColumnTitle::simple("Add Column"), + AddColumnRoute::Algo(r) => match r { + AddAlgoRoute::Base => ColumnTitle::simple("Add Algo Column"), + AddAlgoRoute::LastPerPubkey => ColumnTitle::simple("Add Last Notes Column"), + }, AddColumnRoute::UndecidedNotification => { ColumnTitle::simple("Add Notifications Column") } diff --git a/crates/notedeck_columns/src/storage/decks.rs b/crates/notedeck_columns/src/storage/decks.rs index 3c4b9c2b..8bd1389b 100644 --- a/crates/notedeck_columns/src/storage/decks.rs +++ b/crates/notedeck_columns/src/storage/decks.rs @@ -3,6 +3,8 @@ use std::{collections::HashMap, fmt, str::FromStr}; use enostr::{NoteId, Pubkey}; use nostrdb::Ndb; use serde::{Deserialize, Serialize}; +use strum::IntoEnumIterator; +use strum_macros::EnumIter; use tracing::{error, info}; use crate::{ @@ -10,8 +12,8 @@ use crate::{ column::{Columns, IntermediaryRoute}, decks::{Deck, Decks, DecksCache}, route::Route, - timeline::{kind::ListKind, PubkeySource, TimelineKind, TimelineRoute}, - ui::add_column::AddColumnRoute, + timeline::{kind::ListKind, AlgoTimeline, PubkeySource, TimelineKind, TimelineRoute}, + ui::add_column::{AddAlgoRoute, AddColumnRoute}, Error, }; @@ -299,7 +301,7 @@ fn deserialize_columns(ndb: &Ndb, deck_user: &[u8; 32], serialized: Vec Result { + Ok(parse_selection(serialized)) + } +} + +#[derive(Clone, PartialEq, Eq, Debug, EnumIter)] +enum AlgoKeyword { + LastPerPubkey, +} + +impl AlgoKeyword { + #[inline] + pub fn name(&self) -> &'static str { + match self { + AlgoKeyword::LastPerPubkey => "last_per_pubkey", + } + } +} + +#[derive(Clone, PartialEq, Eq, Debug, EnumIter)] +enum ListKeyword { + Contact, +} + +impl ListKeyword { + #[inline] + pub fn name(&self) -> &'static str { + match self { + ListKeyword::Contact => "contact", + } + } +} + +#[derive(Clone, PartialEq, Eq, Debug, EnumIter)] +enum PubkeySourceKeyword { + Explicit, + DeckAuthor, +} + +impl PubkeySourceKeyword { + #[inline] + pub fn name(&self) -> &'static str { + match self { + PubkeySourceKeyword::Explicit => "explicit", + PubkeySourceKeyword::DeckAuthor => "deck_author", + } + } +} + +#[derive(Clone, PartialEq, Eq, Debug, EnumIter)] enum Keyword { Notifs, Universe, - Contact, - Explicit, - DeckAuthor, Profile, Hashtag, Generic, @@ -350,6 +408,7 @@ enum Keyword { Relay, Compose, Column, + AlgoSelection, NotificationSelection, ExternalNotifSelection, HashtagSelection, @@ -361,60 +420,104 @@ enum Keyword { } impl Keyword { - const MAPPING: &'static [(&'static str, Keyword, bool)] = &[ - ("notifs", Keyword::Notifs, false), - ("universe", Keyword::Universe, false), - ("contact", Keyword::Contact, false), - ("explicit", Keyword::Explicit, true), - ("deck_author", Keyword::DeckAuthor, false), - ("profile", Keyword::Profile, false), - ("hashtag", Keyword::Hashtag, true), - ("generic", Keyword::Generic, false), - ("thread", Keyword::Thread, true), - ("reply", Keyword::Reply, true), - ("quote", Keyword::Quote, true), - ("account", Keyword::Account, false), - ("show", Keyword::Show, false), - ("new", Keyword::New, false), - ("relay", Keyword::Relay, false), - ("compose", Keyword::Compose, false), - ("column", Keyword::Column, false), - ( - "notification_selection", - Keyword::NotificationSelection, - false, - ), - ( - "external_notif_selection", - Keyword::ExternalNotifSelection, - false, - ), - ("hashtag_selection", Keyword::HashtagSelection, false), - ("support", Keyword::Support, false), - ("deck", Keyword::Deck, false), - ("edit", Keyword::Edit, true), - ]; - - fn has_payload(&self) -> bool { - Keyword::MAPPING - .iter() - .find(|(_, keyword, _)| keyword == self) - .map(|(_, _, has_payload)| *has_payload) - .unwrap_or(false) + fn name(&self) -> &'static str { + match self { + Keyword::Notifs => "notifs", + Keyword::Universe => "universe", + Keyword::Profile => "profile", + Keyword::Hashtag => "hashtag", + Keyword::Generic => "generic", + Keyword::Thread => "thread", + Keyword::Reply => "reply", + Keyword::Quote => "quote", + Keyword::Account => "account", + Keyword::Show => "show", + Keyword::New => "new", + Keyword::Relay => "relay", + Keyword::Compose => "compose", + Keyword::Column => "column", + Keyword::AlgoSelection => "algo_selection", + Keyword::NotificationSelection => "notification_selection", + Keyword::ExternalNotifSelection => "external_notif_selection", + Keyword::IndividualSelection => "individual_selection", + Keyword::ExternalIndividualSelection => "external_individual_selection", + Keyword::HashtagSelection => "hashtag_selection", + Keyword::Support => "support", + Keyword::Deck => "deck", + Keyword::Edit => "edit", + } } } impl fmt::Display for Keyword { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if let Some(name) = Keyword::MAPPING - .iter() - .find(|(_, keyword, _)| keyword == self) - .map(|(name, _, _)| *name) - { - write!(f, "{}", name) - } else { - write!(f, "UnknownKeyword") + write!(f, "{}", self.name()) + } +} + +impl fmt::Display for AlgoKeyword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name()) + } +} + +impl fmt::Display for ListKeyword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name()) + } +} + +impl FromStr for PubkeySourceKeyword { + type Err = Error; + + fn from_str(serialized: &str) -> Result { + for keyword in Self::iter() { + if serialized == keyword.name() { + return Ok(keyword); + } } + + Err(Error::Generic( + "Could not convert string to Keyword enum".to_owned(), + )) + } +} + +impl FromStr for ListKeyword { + type Err = Error; + + fn from_str(serialized: &str) -> Result { + for keyword in Self::iter() { + if serialized == keyword.name() { + return Ok(keyword); + } + } + + Err(Error::Generic( + "Could not convert string to Keyword enum".to_owned(), + )) + } +} + +impl fmt::Display for PubkeySourceKeyword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name()) + } +} + +impl FromStr for AlgoKeyword { + type Err = Error; + + fn from_str(serialized: &str) -> Result { + for keyword in Self::iter() { + if serialized == keyword.name() { + return Ok(keyword); + } + } + + Err(Error::Generic( + "Could not convert string to Keyword enum".to_owned(), + )) } } @@ -422,13 +525,15 @@ impl FromStr for Keyword { type Err = Error; fn from_str(serialized: &str) -> Result { - Keyword::MAPPING - .iter() - .find(|(name, _, _)| *name == serialized) - .map(|(_, keyword, _)| keyword.clone()) - .ok_or(Error::Generic( - "Could not convert string to Keyword enum".to_owned(), - )) + for keyword in Self::iter() { + if serialized == keyword.name() { + return Ok(keyword); + } + } + + Err(Error::Generic( + "Could not convert string to Keyword enum".to_owned(), + )) } } @@ -458,10 +563,19 @@ fn serialize_route(route: &Route, columns: &Columns) -> Option { match &timeline.kind { TimelineKind::List(list_kind) => match list_kind { ListKind::Contact(pubkey_source) => { - selections.push(Selection::Keyword(Keyword::Contact)); + selections.push(Selection::List(ListKeyword::Contact)); selections.extend(generate_pubkey_selections(pubkey_source)); } }, + TimelineKind::Algo(AlgoTimeline::LastPerPubkey(list_kind)) => { + match list_kind { + ListKind::Contact(pk_src) => { + selections.push(Selection::Algo(AlgoKeyword::LastPerPubkey)); + selections.push(Selection::List(ListKeyword::Contact)); + selections.extend(generate_pubkey_selections(pk_src)); + } + } + } TimelineKind::Notifications(pubkey_source) => { selections.push(Selection::Keyword(Keyword::Notifs)); selections.extend(generate_pubkey_selections(pubkey_source)); @@ -493,7 +607,7 @@ fn serialize_route(route: &Route, columns: &Columns) -> Option { } TimelineRoute::Profile(pubkey) => { selections.push(Selection::Keyword(Keyword::Profile)); - selections.push(Selection::Keyword(Keyword::Explicit)); + selections.push(Selection::PubkeySource(PubkeySourceKeyword::Explicit)); selections.push(Selection::Payload(pubkey.hex())); } TimelineRoute::Reply(note_id) => { @@ -518,6 +632,16 @@ fn serialize_route(route: &Route, columns: &Columns) -> Option { selections.push(Selection::Keyword(Keyword::Column)); match add_column_route { AddColumnRoute::Base => (), + AddColumnRoute::Algo(algo_route) => match algo_route { + AddAlgoRoute::Base => { + selections.push(Selection::Keyword(Keyword::AlgoSelection)) + } + + AddAlgoRoute::LastPerPubkey => { + selections.push(Selection::Keyword(Keyword::AlgoSelection)); + selections.push(Selection::Algo(AlgoKeyword::LastPerPubkey)); + } + }, AddColumnRoute::UndecidedNotification => { selections.push(Selection::Keyword(Keyword::NotificationSelection)) } @@ -569,109 +693,149 @@ fn generate_pubkey_selections(source: &PubkeySource) -> Vec { let mut selections = Vec::new(); match source { PubkeySource::Explicit(pubkey) => { - selections.push(Selection::Keyword(Keyword::Explicit)); + selections.push(Selection::PubkeySource(PubkeySourceKeyword::Explicit)); selections.push(Selection::Payload(pubkey.hex())); } PubkeySource::DeckAuthor => { - selections.push(Selection::Keyword(Keyword::DeckAuthor)); + selections.push(Selection::PubkeySource(PubkeySourceKeyword::DeckAuthor)); } } selections } +/// Parses a selection +fn parse_selection(token: &str) -> Selection { + AlgoKeyword::from_str(token) + .map(Selection::Algo) + .or_else(|_| ListKeyword::from_str(token).map(Selection::List)) + .or_else(|_| PubkeySourceKeyword::from_str(token).map(Selection::PubkeySource)) + .or_else(|_| Keyword::from_str(token).map(Selection::Keyword)) + .unwrap_or_else(|_| Selection::Payload(token.to_owned())) +} + impl Selection { - fn from_serialized(serialized: &str) -> Vec { + fn from_serialized(buffer: &str) -> Vec { let mut selections = Vec::new(); let seperator = ":"; + let sep_len = seperator.len(); + let mut pos = 0; - let mut serialized_copy = serialized.to_string(); - let mut buffer = serialized_copy.as_mut(); - - let mut next_is_payload = false; - while let Some(index) = buffer.find(seperator) { - if let Ok(keyword) = Keyword::from_str(&buffer[..index]) { - selections.push(Selection::Keyword(keyword.clone())); - if keyword.has_payload() { - next_is_payload = true; - } - } - - buffer = &mut buffer[index + seperator.len()..]; + while let Some(offset) = buffer[pos..].find(seperator) { + selections.push(parse_selection(&buffer[pos..pos + offset])); + pos = pos + offset + sep_len; } - if next_is_payload { - selections.push(Selection::Payload(buffer.to_string())); - } else if let Ok(keyword) = Keyword::from_str(buffer) { - selections.push(Selection::Keyword(keyword.clone())); - } + selections.push(parse_selection(&buffer[pos..])); selections } } -fn selections_to_route(selections: Vec) -> Option { +/// Parse an explicit:abdef... or deck_author from a Selection token stream. +/// +/// Also handle the case where there is nothing. We assume this means deck_author. +fn parse_pubkey_src_selection(tokens: &[Selection]) -> Option { + match tokens.first() { + // we handle bare payloads and assume they are explicit pubkey sources + Some(Selection::Payload(hex)) => { + let pk = Pubkey::from_hex(hex.as_str()).ok()?; + Some(PubkeySource::Explicit(pk)) + } + + Some(Selection::PubkeySource(PubkeySourceKeyword::Explicit)) => { + if let Selection::Payload(hex) = tokens.get(1)? { + let pk = Pubkey::from_hex(hex.as_str()).ok()?; + Some(PubkeySource::Explicit(pk)) + } else { + None + } + } + + None | Some(Selection::PubkeySource(PubkeySourceKeyword::DeckAuthor)) => { + Some(PubkeySource::DeckAuthor) + } + + Some(Selection::Keyword(_kw)) => None, + Some(Selection::Algo(_kw)) => None, + Some(Selection::List(_kw)) => None, + } +} + +/// Parse ListKinds from Selections +fn parse_list_kind_selections(tokens: &[Selection]) -> Option { + // only list selections are valid in this position + let list_kw = if let Selection::List(list_kw) = tokens.first()? { + list_kw + } else { + return None; + }; + + let pubkey_src = parse_pubkey_src_selection(&tokens[1..])?; + + Some(match list_kw { + ListKeyword::Contact => ListKind::contact_list(pubkey_src), + }) +} + +fn selections_to_route(selections: &[Selection]) -> Option { match selections.first()? { - Selection::Keyword(Keyword::Contact) => match selections.get(1)? { - Selection::Keyword(Keyword::Explicit) => { - if let Selection::Payload(hex) = selections.get(2)? { - Some(CleanIntermediaryRoute::ToTimeline( - TimelineKind::contact_list(PubkeySource::Explicit( - Pubkey::from_hex(hex.as_str()).ok()?, - )), - )) - } else { - None + Selection::Keyword(Keyword::AlgoSelection) => { + let r = match selections.get(1) { + None => AddColumnRoute::Algo(AddAlgoRoute::Base), + Some(Selection::Algo(algo_kw)) => match algo_kw { + AlgoKeyword::LastPerPubkey => AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey), + }, + // other keywords are invalid here + Some(_) => { + return None; } - } - Selection::Keyword(Keyword::DeckAuthor) => Some(CleanIntermediaryRoute::ToTimeline( - TimelineKind::contact_list(PubkeySource::DeckAuthor), - )), - _ => None, - }, - Selection::Keyword(Keyword::Notifs) => match selections.get(1)? { - Selection::Keyword(Keyword::Explicit) => { - if let Selection::Payload(hex) = selections.get(2)? { - Some(CleanIntermediaryRoute::ToTimeline( - TimelineKind::notifications(PubkeySource::Explicit( - Pubkey::from_hex(hex.as_str()).ok()?, - )), - )) - } else { - None + }; + + Some(CleanIntermediaryRoute::ToRoute(Route::AddColumn(r))) + } + + // Algorithm timelines + Selection::Algo(algo_kw) => { + let timeline_kind = match algo_kw { + AlgoKeyword::LastPerPubkey => { + let list_kind = parse_list_kind_selections(&selections[1..])?; + TimelineKind::last_per_pubkey(list_kind) } - } - Selection::Keyword(Keyword::DeckAuthor) => Some(CleanIntermediaryRoute::ToTimeline( - TimelineKind::notifications(PubkeySource::DeckAuthor), - )), - _ => None, - }, - Selection::Keyword(Keyword::Profile) => match selections.get(1)? { - Selection::Keyword(Keyword::Explicit) => { - if let Selection::Payload(hex) = selections.get(2)? { - Some(CleanIntermediaryRoute::ToTimeline(TimelineKind::profile( - PubkeySource::Explicit(Pubkey::from_hex(hex.as_str()).ok()?), - ))) - } else { - None - } - } - Selection::Keyword(Keyword::DeckAuthor) => Some(CleanIntermediaryRoute::ToTimeline( - TimelineKind::profile(PubkeySource::DeckAuthor), - )), - Selection::Keyword(Keyword::Edit) => { - if let Selection::Payload(hex) = selections.get(2)? { - Some(CleanIntermediaryRoute::ToRoute(Route::EditProfile( - Pubkey::from_hex(hex.as_str()).ok()?, - ))) - } else { - None - } - } - _ => None, - }, + }; + + Some(CleanIntermediaryRoute::ToTimeline(timeline_kind)) + } + + // We never have PubkeySource keywords at the top level + Selection::PubkeySource(_pk_src) => None, + + Selection::List(ListKeyword::Contact) => { + // only pubkey/src is allowed in this position + let pubkey_src = parse_pubkey_src_selection(&selections[1..])?; + Some(CleanIntermediaryRoute::ToTimeline( + TimelineKind::contact_list(pubkey_src), + )) + } + + Selection::Keyword(Keyword::Notifs) => { + let pubkey_src = parse_pubkey_src_selection(&selections[1..])?; + Some(CleanIntermediaryRoute::ToTimeline( + TimelineKind::notifications(pubkey_src), + )) + } + + Selection::Keyword(Keyword::Profile) => { + // we only expect PubkeySource in this position + let pubkey_src = parse_pubkey_src_selection(&selections[1..])?; + Some(CleanIntermediaryRoute::ToTimeline(TimelineKind::profile( + pubkey_src, + ))) + } + Selection::Keyword(Keyword::Universe) => { Some(CleanIntermediaryRoute::ToTimeline(TimelineKind::Universe)) } + Selection::Keyword(Keyword::Hashtag) => { if let Selection::Payload(hashtag) = selections.get(1)? { Some(CleanIntermediaryRoute::ToTimeline(TimelineKind::Hashtag( @@ -681,9 +845,11 @@ fn selections_to_route(selections: Vec) -> Option { Some(CleanIntermediaryRoute::ToTimeline(TimelineKind::Generic)) } + Selection::Keyword(Keyword::Thread) => { if let Selection::Payload(hex) = selections.get(1)? { Some(CleanIntermediaryRoute::ToRoute(Route::thread( @@ -693,6 +859,7 @@ fn selections_to_route(selections: Vec) -> Option { if let Selection::Payload(hex) = selections.get(1)? { Some(CleanIntermediaryRoute::ToRoute(Route::reply( @@ -770,9 +937,7 @@ fn selections_to_route(selections: Vec) -> Option None, }, Selection::Payload(_) - | Selection::Keyword(Keyword::Explicit) | Selection::Keyword(Keyword::New) - | Selection::Keyword(Keyword::DeckAuthor) | Selection::Keyword(Keyword::Show) | Selection::Keyword(Keyword::NotificationSelection) | Selection::Keyword(Keyword::ExternalNotifSelection) @@ -788,6 +953,9 @@ impl fmt::Display for Selection { match self { Selection::Keyword(keyword) => write!(f, "{}", keyword), Selection::Payload(payload) => write!(f, "{}", payload), + Selection::Algo(algo_kw) => write!(f, "{}", algo_kw), + Selection::List(list_kw) => write!(f, "{}", list_kw), + Selection::PubkeySource(pk_src_kw) => write!(f, "{}", pk_src_kw), } } } diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs index c6d072cc..3d219140 100644 --- a/crates/notedeck_columns/src/timeline/kind.rs +++ b/crates/notedeck_columns/src/timeline/kind.rs @@ -35,6 +35,10 @@ impl PubkeySource { } impl ListKind { + pub fn contact_list(pk_src: PubkeySource) -> Self { + ListKind::Contact(pk_src) + } + pub fn pubkey_source(&self) -> Option<&PubkeySource> { match self { ListKind::Contact(pk_src) => Some(pk_src), @@ -54,6 +58,9 @@ impl ListKind { pub enum TimelineKind { List(ListKind), + /// The last not per pubkey + Algo(AlgoTimeline), + Notifications(PubkeySource), Profile(PubkeySource), @@ -69,10 +76,19 @@ pub enum TimelineKind { Hashtag(String), } +/// Hardcoded algo timelines +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AlgoTimeline { + /// LastPerPubkey: a special nostr query that fetches the last N + /// notes for each pubkey on the list + LastPerPubkey(ListKind), +} + impl Display for TimelineKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TimelineKind::List(ListKind::Contact(_src)) => f.write_str("Contacts"), + TimelineKind::Algo(AlgoTimeline::LastPerPubkey(_lk)) => f.write_str("Last Notes"), TimelineKind::Generic => f.write_str("Timeline"), TimelineKind::Notifications(_) => f.write_str("Notifications"), TimelineKind::Profile(_) => f.write_str("Profile"), @@ -87,6 +103,7 @@ impl TimelineKind { pub fn pubkey_source(&self) -> Option<&PubkeySource> { match self { TimelineKind::List(list_kind) => list_kind.pubkey_source(), + TimelineKind::Algo(AlgoTimeline::LastPerPubkey(list_kind)) => list_kind.pubkey_source(), TimelineKind::Notifications(pk_src) => Some(pk_src), TimelineKind::Profile(pk_src) => Some(pk_src), TimelineKind::Universe => None, @@ -96,8 +113,27 @@ impl TimelineKind { } } + /// Some feeds are not realtime, like certain algo feeds + pub fn should_subscribe_locally(&self) -> bool { + match self { + TimelineKind::Algo(AlgoTimeline::LastPerPubkey(_list_kind)) => false, + + TimelineKind::List(_list_kind) => true, + TimelineKind::Notifications(_pk_src) => true, + TimelineKind::Profile(_pk_src) => true, + TimelineKind::Universe => true, + TimelineKind::Generic => true, + TimelineKind::Hashtag(_ht) => true, + TimelineKind::Thread(_ht) => true, + } + } + + pub fn last_per_pubkey(list_kind: ListKind) -> Self { + TimelineKind::Algo(AlgoTimeline::LastPerPubkey(list_kind)) + } + pub fn contact_list(pk: PubkeySource) -> Self { - TimelineKind::List(ListKind::Contact(pk)) + TimelineKind::List(ListKind::contact_list(pk)) } pub fn is_contacts(&self) -> bool { @@ -138,6 +174,48 @@ impl TimelineKind { None } + TimelineKind::Algo(AlgoTimeline::LastPerPubkey(ListKind::Contact(pk_src))) => { + let pk = match &pk_src { + PubkeySource::DeckAuthor => default_user?, + 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, &[contact_filter.clone()], 1) + .expect("contact query failed?"); + + let kind_fn = TimelineKind::last_per_pubkey; + let tabs = TimelineTab::only_notes_and_replies(); + + if results.is_empty() { + return Some(Timeline::new( + kind_fn(ListKind::contact_list(pk_src)), + FilterState::needs_remote(vec![contact_filter.clone()]), + tabs, + )); + } + + let list_kind = ListKind::contact_list(pk_src); + + match Timeline::last_per_pubkey(&results[0].note, &list_kind) { + Err(Error::App(notedeck::Error::Filter(FilterError::EmptyContactList))) => { + Some(Timeline::new( + kind_fn(list_kind), + FilterState::needs_remote(vec![contact_filter]), + tabs, + )) + } + Err(e) => { + error!("Unexpected error: {e}"); + None + } + Ok(tl) => Some(tl), + } + } + TimelineKind::Profile(pk_src) => { let pk = match &pk_src { PubkeySource::DeckAuthor => default_user?, @@ -222,6 +300,9 @@ impl TimelineKind { TimelineKind::List(list_kind) => match list_kind { ListKind::Contact(_pubkey_source) => ColumnTitle::simple("Contacts"), }, + TimelineKind::Algo(AlgoTimeline::LastPerPubkey(list_kind)) => match list_kind { + ListKind::Contact(_pubkey_source) => ColumnTitle::simple("Contacts (last notes)"), + }, TimelineKind::Notifications(_pubkey_source) => ColumnTitle::simple("Notifications"), TimelineKind::Profile(_pubkey_source) => ColumnTitle::needs_db(self), TimelineKind::Thread(_root_id) => ColumnTitle::simple("Thread"), diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs index 9d6b7474..8fc81e6a 100644 --- a/crates/notedeck_columns/src/timeline/mod.rs +++ b/crates/notedeck_columns/src/timeline/mod.rs @@ -4,6 +4,7 @@ use crate::{ error::Error, subscriptions::{self, SubKind, Subscriptions}, thread::Thread, + timeline::kind::ListKind, Result, }; @@ -29,7 +30,7 @@ pub mod kind; pub mod route; pub use cache::{TimelineCache, TimelineCacheKey}; -pub use kind::{ColumnTitle, PubkeySource, TimelineKind}; +pub use kind::{AlgoTimeline, ColumnTitle, PubkeySource, TimelineKind}; pub use route::TimelineRoute; #[derive(Debug, Hash, Copy, Clone, Eq, PartialEq)] @@ -227,6 +228,18 @@ impl Timeline { ) } + pub fn last_per_pubkey(list: &Note, list_kind: &ListKind) -> Result { + let kind = 1; + let notes_per_pk = 1; + let filter = filter::last_n_per_pubkey_from_tags(list, kind, notes_per_pk)?; + + Ok(Timeline::new( + TimelineKind::last_per_pubkey(list_kind.clone()), + FilterState::ready(filter), + TimelineTab::only_notes_and_replies(), + )) + } + pub fn hashtag(hashtag: String) -> Self { let filter = Filter::new() .kinds([1]) @@ -397,6 +410,11 @@ impl Timeline { note_cache: &mut NoteCache, reversed: bool, ) -> Result<()> { + if !self.kind.should_subscribe_locally() { + // don't need to poll for timelines that don't have local subscriptions + return Ok(()); + } + let sub = self .subscription .ok_or(Error::App(notedeck::Error::no_active_sub()))?; @@ -601,13 +619,20 @@ fn setup_initial_timeline( note_cache: &mut NoteCache, filters: &[Filter], ) -> Result<()> { - timeline.subscription = Some(ndb.subscribe(filters)?); + // some timelines are one-shot and a refreshed, like last_per_pubkey algo feed + if timeline.kind.should_subscribe_locally() { + timeline.subscription = Some(ndb.subscribe(filters)?); + } let txn = Transaction::new(ndb)?; debug!( "querying nostrdb sub {:?} {:?}", timeline.subscription, timeline.filter ); - let lim = filters[0].limit().unwrap_or(filter::default_limit()) as i32; + + let mut lim = 0i32; + for filter in filters { + lim += filter.limit().unwrap_or(1) as i32; + } let notes: Vec = ndb .query(&txn, filters, lim)? diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs index eb80398e..cd98f280 100644 --- a/crates/notedeck_columns/src/ui/add_column.rs +++ b/crates/notedeck_columns/src/ui/add_column.rs @@ -10,7 +10,8 @@ use nostrdb::{Ndb, Transaction}; use crate::{ login_manager::AcquireKeyState, - timeline::{PubkeySource, Timeline, TimelineKind}, + route::Route, + timeline::{kind::ListKind, PubkeySource, Timeline, TimelineKind}, ui::anim::ICON_EXPANSION_MULTIPLE, Damus, }; @@ -24,22 +25,35 @@ pub enum AddColumnResponse { UndecidedNotification, ExternalNotification, Hashtag, + Algo(AlgoOption), UndecidedIndividual, ExternalIndividual, } pub enum NotificationColumnType { - Home, + Contacts, External, } +#[derive(Clone, Debug)] +pub enum Decision { + Undecided, + Decided(T), +} + +#[derive(Clone, Debug)] +pub enum AlgoOption { + LastPerPubkey(Decision), +} + #[derive(Clone, Debug)] enum AddColumnOption { Universe, UndecidedNotification, ExternalNotification, + Algo(AlgoOption), Notification(PubkeySource), - Home(PubkeySource), + Contacts(PubkeySource), UndecidedHashtag, Hashtag(String), UndecidedIndividual, @@ -47,12 +61,19 @@ enum AddColumnOption { Individual(PubkeySource), } +#[derive(Clone, Copy, Eq, PartialEq, Debug)] +pub enum AddAlgoRoute { + Base, + LastPerPubkey, +} + #[derive(Clone, Copy, Eq, PartialEq, Debug)] pub enum AddColumnRoute { Base, UndecidedNotification, ExternalNotification, Hashtag, + Algo(AddAlgoRoute), UndecidedIndividual, ExternalIndividual, } @@ -64,6 +85,7 @@ impl AddColumnOption { cur_account: Option<&UserAccount>, ) -> Option { match self { + AddColumnOption::Algo(algo_option) => Some(AddColumnResponse::Algo(algo_option)), AddColumnOption::Universe => TimelineKind::Universe .into_timeline(ndb, None) .map(AddColumnResponse::Timeline), @@ -73,7 +95,7 @@ impl AddColumnOption { AddColumnOption::UndecidedNotification => { Some(AddColumnResponse::UndecidedNotification) } - AddColumnOption::Home(pubkey) => { + AddColumnOption::Contacts(pubkey) => { let tlk = TimelineKind::contact_list(pubkey); tlk.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) .map(AddColumnResponse::Timeline) @@ -151,6 +173,40 @@ impl<'a> AddColumnView<'a> { }) } + fn algo_last_per_pk_ui(&mut self, ui: &mut Ui) -> Option { + let algo_option = ColumnOptionData { + title: "Contact List", + description: "Source the last note for each user in your contact list", + icon: egui::include_image!("../../../../assets/icons/home_icon_dark_4x.png"), + option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Decided( + ListKind::contact_list(PubkeySource::DeckAuthor), + ))), + }; + + let option = algo_option.option.clone(); + if self.column_option_ui(ui, algo_option).clicked() { + option.take_as_response(self.ndb, self.cur_account) + } else { + None + } + } + + fn algo_ui(&mut self, ui: &mut Ui) -> Option { + let algo_option = ColumnOptionData { + title: "Last Note per User", + description: "Show the last note for each user from a list", + icon: egui::include_image!("../../../../assets/icons/universe_icon_dark_4x.png"), + option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)), + }; + + let option = algo_option.option.clone(); + if self.column_option_ui(ui, algo_option).clicked() { + option.take_as_response(self.ndb, self.cur_account) + } else { + None + } + } + fn individual_ui(&mut self, ui: &mut Ui) -> Option { let mut selected_option: Option = None; for column_option_data in self.get_individual_options() { @@ -352,10 +408,10 @@ impl<'a> AddColumnView<'a> { }; vec.push(ColumnOptionData { - title: "Home timeline", - description: "See recommended notes first", + title: "Contacts", + description: "See notes from your contacts", icon: egui::include_image!("../../../../assets/icons/home_icon_dark_4x.png"), - option: AddColumnOption::Home(source.clone()), + option: AddColumnOption::Contacts(source.clone()), }); } vec.push(ColumnOptionData { @@ -376,6 +432,12 @@ impl<'a> AddColumnView<'a> { icon: egui::include_image!("../../../../assets/icons/profile_icon_4x.png"), option: AddColumnOption::UndecidedIndividual, }); + vec.push(ColumnOptionData { + title: "Algo", + description: "Algorithmic feeds to aid in note discovery", + icon: egui::include_image!("../../../../assets/icons/plus_icon_4x.png"), + option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)), + }); vec } @@ -486,6 +548,10 @@ pub fn render_add_column_routes( ); let resp = match route { AddColumnRoute::Base => add_column_view.ui(ui), + AddColumnRoute::Algo(r) => match r { + AddAlgoRoute::Base => add_column_view.algo_ui(ui), + AddAlgoRoute::LastPerPubkey => add_column_view.algo_last_per_pk_ui(ui), + }, AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui), AddColumnRoute::Hashtag => hashtag_ui(ui, ctx.ndb, &mut app.view_state.id_string_map), @@ -511,13 +577,66 @@ pub fn render_add_column_routes( app.columns_mut(ctx.accounts) .add_timeline_to_column(col, timeline); } + + AddColumnResponse::Algo(algo_option) => match algo_option { + // If we are undecided, we simply route to the LastPerPubkey + // algo route selection + AlgoOption::LastPerPubkey(Decision::Undecided) => { + app.columns_mut(ctx.accounts) + .column_mut(col) + .router_mut() + .route_to(Route::AddColumn(AddColumnRoute::Algo( + AddAlgoRoute::LastPerPubkey, + ))); + } + + // We have a decision on where we want the last per pubkey + // source to be, so let;s create a timeline from that and + // add it to our list of timelines + AlgoOption::LastPerPubkey(Decision::Decided(list_kind)) => { + let maybe_timeline = { + let default_user = ctx + .accounts + .get_selected_account() + .as_ref() + .map(|sa| sa.pubkey.bytes()); + + TimelineKind::last_per_pubkey(list_kind.clone()) + .into_timeline(ctx.ndb, default_user) + }; + + if let Some(mut timeline) = maybe_timeline { + crate::timeline::setup_new_timeline( + &mut timeline, + ctx.ndb, + &mut app.subscriptions, + ctx.pool, + ctx.note_cache, + app.since_optimize, + ctx.accounts + .get_selected_account() + .as_ref() + .map(|sa| &sa.pubkey), + ); + + app.columns_mut(ctx.accounts) + .add_timeline_to_column(col, timeline); + } else { + // we couldn't fetch the timeline yet... let's let + // the user know ? + + // TODO: spin off the list search here instead + + ui.label(format!("error: could not find {:?}", &list_kind)); + } + } + }, + AddColumnResponse::UndecidedNotification => { app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() - .route_to(crate::route::Route::AddColumn( - AddColumnRoute::UndecidedNotification, - )); + .route_to(Route::AddColumn(AddColumnRoute::UndecidedNotification)); } AddColumnResponse::ExternalNotification => { app.columns_mut(ctx.accounts)