@@ -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")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user