wip algo timelines

Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
William Casarin
2024-12-25 19:06:04 -08:00
parent 7afe3b7d7c
commit 662755550f
5 changed files with 556 additions and 159 deletions

View File

@@ -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")
}

View File

@@ -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<Vec<Stri
let mut cur_routes = Vec::new();
for serialized_route in serialized_routes {
let selections = Selection::from_serialized(&serialized_route);
if let Some(route_intermediary) = selections_to_route(selections.clone()) {
if let Some(route_intermediary) = selections_to_route(&selections) {
if let Some(ir) = route_intermediary.intermediary_route(ndb, Some(deck_user)) {
match &ir {
IntermediaryRoute::Route(Route::Timeline(TimelineRoute::Thread(_)))
@@ -325,19 +327,75 @@ fn deserialize_columns(ndb: &Ndb, deck_user: &[u8; 32], serialized: Vec<Vec<Stri
cols
}
/// Different token types for our deck serializer/deserializer
///
/// We have more than one token type so that we can avoid match catch-alls
/// in different parts of our parser.
#[derive(Clone, Debug)]
enum Selection {
Keyword(Keyword),
Algo(AlgoKeyword),
List(ListKeyword),
PubkeySource(PubkeySourceKeyword),
Payload(String),
}
#[derive(Clone, PartialEq, Debug)]
impl FromStr for Selection {
type Err = Error;
fn from_str(serialized: &str) -> Result<Self, Self::Err> {
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<Self, Self::Err> {
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<Self, Self::Err> {
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<Self, Self::Err> {
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<Self, Self::Err> {
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<String> {
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<String> {
}
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<String> {
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<Selection> {
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<Self> {
fn from_serialized(buffer: &str) -> Vec<Self> {
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<Selection>) -> Option<CleanIntermediaryRoute> {
/// 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<PubkeySource> {
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<ListKind> {
// 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<CleanIntermediaryRoute> {
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<Selection>) -> Option<CleanIntermediaryRo
None
}
}
Selection::Keyword(Keyword::Generic) => {
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<Selection>) -> Option<CleanIntermediaryRo
None
}
}
Selection::Keyword(Keyword::Reply) => {
if let Selection::Payload(hex) = selections.get(1)? {
Some(CleanIntermediaryRoute::ToRoute(Route::reply(
@@ -770,9 +937,7 @@ fn selections_to_route(selections: Vec<Selection>) -> Option<CleanIntermediaryRo
_ => 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),
}
}
}

View File

@@ -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"),

View File

@@ -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<Self> {
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<NoteRef> = ndb
.query(&txn, filters, lim)?

View File

@@ -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<T> {
Undecided,
Decided(T),
}
#[derive(Clone, Debug)]
pub enum AlgoOption {
LastPerPubkey(Decision<ListKind>),
}
#[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<AddColumnResponse> {
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<AddColumnResponse> {
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<AddColumnResponse> {
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<AddColumnResponse> {
let mut selected_option: Option<AddColumnResponse> = 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)