commit cae6418472ffda09ae5e2cb41a4736e04812bc3b
parent 2a2bbd468643cc442175cfa395b3ae2a0f261838
Author: William Casarin <jb55@jb55.com>
Date: Thu, 26 Feb 2026 11:21:41 -0800
columns: add NIP-51 people list support (kind 30000)
Add the ability to load NIP-51 people lists as timeline columns. Users
can select from their kind 30000 follow sets in the Add Column UI, and
the selected list's members' notes populate a timeline.
Key design decisions:
- Mirror the Contact fetch pattern with FetchingRemoteType::PeopleList
and GotRemoteType::PeopleList, avoiding threading ndb through
send_initial_timeline_filter
- Use Nip51SetCache for the list selection UI so lists load
progressively from relays rather than requiring a sync first
- Add FilterError::EmptyList for semantically correct people list errors
- Reuse hybrid_contacts_filter which generically reads "p" tags
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
8 files changed, 385 insertions(+), 30 deletions(-)
diff --git a/crates/notedeck/src/error.rs b/crates/notedeck/src/error.rs
@@ -67,6 +67,9 @@ pub enum FilterError {
#[error("empty contact list")]
EmptyContactList,
+ #[error("empty list")]
+ EmptyList,
+
#[error("filter not ready")]
FilterNotReady,
}
diff --git a/crates/notedeck/src/filter.rs b/crates/notedeck/src/filter.rs
@@ -48,6 +48,9 @@ impl FilterStates {
GotRemoteType::Contact => Some(GotRemoteResult::Contact {
relay_id: k.to_owned(),
}),
+ GotRemoteType::PeopleList => Some(GotRemoteResult::PeopleList {
+ relay_id: k.to_owned(),
+ }),
};
}
}
@@ -114,18 +117,23 @@ pub enum GotRemoteResult {
Contact {
relay_id: String,
},
+ PeopleList {
+ relay_id: String,
+ },
}
#[derive(Debug, Clone)]
pub enum FetchingRemoteType {
Normal(UnifiedSubscription),
Contact,
+ PeopleList,
}
#[derive(Debug, Clone)]
pub enum GotRemoteType {
Normal(Subscription),
Contact,
+ PeopleList,
}
impl FilterState {
diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs
@@ -229,8 +229,32 @@ fn try_process_event(
}
} else {
// TODO: show loading?
- if matches!(kind, TimelineKind::List(ListKind::Contact(_))) {
- timeline::fetch_contact_list(&mut damus.subscriptions, timeline, app_ctx.accounts);
+ match kind {
+ TimelineKind::List(ListKind::Contact(_))
+ | TimelineKind::Algo(timeline::kind::AlgoTimeline::LastPerPubkey(
+ ListKind::Contact(_),
+ )) => {
+ timeline::fetch_contact_list(
+ &mut damus.subscriptions,
+ timeline,
+ app_ctx.accounts,
+ );
+ }
+ TimelineKind::List(ListKind::PeopleList(plr))
+ | TimelineKind::Algo(timeline::kind::AlgoTimeline::LastPerPubkey(
+ ListKind::PeopleList(plr),
+ )) => {
+ let plr = plr.clone();
+ for relay in &mut app_ctx.pool.relays {
+ timeline::fetch_people_list(
+ &mut damus.subscriptions,
+ relay,
+ timeline,
+ &plr,
+ );
+ }
+ }
+ _ => {}
}
}
}
@@ -424,6 +448,9 @@ fn handle_eose(
notedeck::filter::FetchingRemoteType::Contact => {
FilterState::GotRemote(notedeck::filter::GotRemoteType::Contact)
}
+ notedeck::filter::FetchingRemoteType::PeopleList => {
+ FilterState::GotRemote(notedeck::filter::GotRemoteType::PeopleList)
+ }
};
// We take the subscription id and pass it to the new state of
@@ -896,6 +923,7 @@ fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool {
match timeline_kind {
TimelineKind::List(list_kind) => match list_kind {
ListKind::Contact(_pk) => true,
+ ListKind::PeopleList(_) => true,
},
TimelineKind::Algo(_pk) => true,
diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs
@@ -414,6 +414,11 @@ impl Route {
"Subscribe to someone else's notes",
"Column title for subscribing to external user"
)),
+ AddColumnRoute::PeopleList => ColumnTitle::formatted(tr!(
+ i18n,
+ "Select a People List",
+ "Column title for selecting a people list"
+ )),
},
Route::Support => {
ColumnTitle::formatted(tr!(i18n, "Damus Support", "Column title for support page"))
diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs
@@ -22,15 +22,25 @@ pub enum PubkeySource {
DeckAuthor,
}
-#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq)]
+/// Reference to a NIP-51 people list (kind 30000), identified by author + "d" tag
+#[derive(Debug, Clone, PartialEq, Hash, Eq)]
+pub struct PeopleListRef {
+ pub author: Pubkey,
+ pub identifier: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Hash, Eq)]
pub enum ListKind {
Contact(Pubkey),
+ /// A NIP-51 people list (kind 30000)
+ PeopleList(PeopleListRef),
}
impl ListKind {
pub fn pubkey(&self) -> Option<&Pubkey> {
match self {
Self::Contact(pk) => Some(pk),
+ Self::PeopleList(plr) => Some(&plr.author),
}
}
}
@@ -89,30 +99,34 @@ impl ListKind {
ListKind::Contact(pk)
}
+ pub fn people_list(author: Pubkey, identifier: String) -> Self {
+ ListKind::PeopleList(PeopleListRef { author, identifier })
+ }
+
pub fn parse<'a>(
parser: &mut TokenParser<'a>,
deck_author: &Pubkey,
) -> Result<Self, ParseError<'a>> {
+ let contact = parser.try_parse(|p| {
+ p.parse_all(|p| {
+ p.parse_token("contact")?;
+ let pk_src = PubkeySource::parse_from_tokens(p)?;
+ Ok(ListKind::Contact(*pk_src.as_pubkey(deck_author)))
+ })
+ });
+ if contact.is_ok() {
+ return contact;
+ }
+
parser.parse_all(|p| {
- p.parse_token("contact")?;
+ p.parse_token("people_list")?;
let pk_src = PubkeySource::parse_from_tokens(p)?;
- Ok(ListKind::Contact(*pk_src.as_pubkey(deck_author)))
+ let identifier = p.pull_token()?.to_string();
+ Ok(ListKind::PeopleList(PeopleListRef {
+ author: *pk_src.as_pubkey(deck_author),
+ identifier,
+ }))
})
-
- /* here for u when you need more things to parse
- TokenParser::alt(
- parser,
- &[|p| {
- p.parse_all(|p| {
- p.parse_token("contact")?;
- let pk_src = PubkeySource::parse_from_tokens(p)?;
- Ok(ListKind::Contact(pk_src))
- });
- },|p| {
- // more cases...
- }],
- )
- */
}
pub fn serialize_tokens(&self, writer: &mut TokenWriter) {
@@ -121,6 +135,11 @@ impl ListKind {
writer.write_token("contact");
PubkeySource::pubkey(*pk).serialize_tokens(writer);
}
+ ListKind::PeopleList(plr) => {
+ writer.write_token("people_list");
+ PubkeySource::pubkey(plr.author).serialize_tokens(writer);
+ writer.write_token(&plr.identifier);
+ }
}
}
}
@@ -221,7 +240,7 @@ const NOTIFS_TOKEN_DEPRECATED: &str = "notifs";
const NOTIFS_TOKEN: &str = "notifications";
/// Hardcoded algo timelines
-#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Hash, Clone, PartialEq, Eq)]
pub enum AlgoTimeline {
/// LastPerPubkey: a special nostr query that fetches the last N
/// notes for each pubkey on the list
@@ -440,6 +459,10 @@ impl TimelineKind {
TimelineKind::List(ListKind::contact_list(pk))
}
+ pub fn people_list(author: Pubkey, identifier: String) -> Self {
+ TimelineKind::List(ListKind::people_list(author, identifier))
+ }
+
pub fn search(s: String) -> Self {
TimelineKind::Search(SearchQuery::new(s))
}
@@ -470,6 +493,7 @@ impl TimelineKind {
TimelineKind::List(list_k) => match list_k {
ListKind::Contact(pubkey) => contact_filter_state(txn, ndb, pubkey),
+ ListKind::PeopleList(plr) => people_list_filter_state(txn, ndb, plr),
},
// TODO: still need to update this to fetch likes, zaps, etc
@@ -506,6 +530,9 @@ impl TimelineKind {
TimelineKind::Algo(algo_timeline) => match algo_timeline {
AlgoTimeline::LastPerPubkey(list_k) => match list_k {
ListKind::Contact(pubkey) => last_per_pubkey_filter_state(txn, ndb, pubkey),
+ ListKind::PeopleList(plr) => {
+ people_list_last_per_pubkey_filter_state(txn, ndb, plr)
+ }
},
},
@@ -602,6 +629,46 @@ impl TimelineKind {
contact_filter_state(txn, ndb, &pk),
TimelineTab::full_tabs(),
)),
+
+ TimelineKind::List(ListKind::PeopleList(plr)) => Some(Timeline::new(
+ TimelineKind::List(ListKind::PeopleList(plr.clone())),
+ people_list_filter_state(txn, ndb, &plr),
+ TimelineTab::full_tabs(),
+ )),
+
+ TimelineKind::Algo(AlgoTimeline::LastPerPubkey(ListKind::PeopleList(plr))) => {
+ let list_filter = people_list_note_filter(&plr);
+ let results = ndb
+ .query(txn, std::slice::from_ref(&list_filter), 1)
+ .expect("people list query failed?");
+
+ let list_kind = ListKind::PeopleList(plr);
+ let kind_fn = TimelineKind::last_per_pubkey;
+ let tabs = TimelineTab::only_notes_and_replies();
+
+ if results.is_empty() {
+ return Some(Timeline::new(
+ kind_fn(list_kind),
+ FilterState::needs_remote(),
+ tabs,
+ ));
+ }
+
+ match Timeline::last_per_pubkey(&results[0].note, &list_kind) {
+ Err(Error::App(notedeck::Error::Filter(
+ FilterError::EmptyContactList | FilterError::EmptyList,
+ ))) => Some(Timeline::new(
+ kind_fn(list_kind),
+ FilterState::needs_remote(),
+ tabs,
+ )),
+ Err(e) => {
+ error!("Unexpected error: {e}");
+ None
+ }
+ Ok(tl) => Some(tl),
+ }
+ }
}
}
@@ -614,6 +681,7 @@ impl TimelineKind {
ListKind::Contact(_pubkey_source) => {
ColumnTitle::formatted(tr!(i18n, "Contacts", "Column title for contact lists"))
}
+ ListKind::PeopleList(plr) => ColumnTitle::formatted(plr.identifier.clone()),
},
TimelineKind::Algo(AlgoTimeline::LastPerPubkey(list_kind)) => match list_kind {
ListKind::Contact(_pubkey_source) => ColumnTitle::formatted(tr!(
@@ -621,6 +689,9 @@ impl TimelineKind {
"Contacts (last notes)",
"Column title for last notes per contact"
)),
+ ListKind::PeopleList(plr) => {
+ ColumnTitle::formatted(format!("{} (last notes)", plr.identifier))
+ }
},
TimelineKind::Notifications(_pubkey_source) => {
ColumnTitle::formatted(tr!(i18n, "Notifications", "Column title for notifications"))
@@ -791,3 +862,76 @@ fn search_filter(s: &SearchQuery) -> Vec<Filter> {
fn universe_filter() -> Vec<Filter> {
vec![Filter::new().kinds([1]).limit(default_limit()).build()]
}
+
+/// Filter to fetch a kind 30000 people list event by author + d tag
+pub fn people_list_note_filter(plr: &PeopleListRef) -> Filter {
+ Filter::new()
+ .authors([plr.author.bytes()])
+ .kinds([30000])
+ .tags([plr.identifier.as_str()], 'd')
+ .limit(1)
+ .build()
+}
+
+/// Build the filter state for a people list timeline.
+fn people_list_filter_state(txn: &Transaction, ndb: &Ndb, plr: &PeopleListRef) -> FilterState {
+ let list_filter = people_list_note_filter(plr);
+
+ let results = match ndb.query(txn, std::slice::from_ref(&list_filter), 1) {
+ Ok(results) => results,
+ Err(err) => {
+ error!("people list query failed: {err}");
+ return FilterState::Broken(FilterError::EmptyList);
+ }
+ };
+
+ if results.is_empty() {
+ FilterState::needs_remote()
+ } else {
+ let with_hashtags = false;
+ match hybrid_contacts_filter(&results[0].note, None, with_hashtags) {
+ Err(notedeck::Error::Filter(FilterError::EmptyContactList)) => {
+ FilterState::needs_remote()
+ }
+ Err(err) => {
+ error!("Error getting people list filter state: {err}");
+ FilterState::Broken(FilterError::EmptyList)
+ }
+ Ok(filter) => FilterState::ready_hybrid(filter),
+ }
+ }
+}
+
+/// Build the filter state for a last-per-pubkey timeline backed by a people list.
+fn people_list_last_per_pubkey_filter_state(
+ txn: &Transaction,
+ ndb: &Ndb,
+ plr: &PeopleListRef,
+) -> FilterState {
+ let list_filter = people_list_note_filter(plr);
+
+ let results = match ndb.query(txn, std::slice::from_ref(&list_filter), 1) {
+ Ok(results) => results,
+ Err(err) => {
+ error!("people list query failed: {err}");
+ return FilterState::Broken(FilterError::EmptyList);
+ }
+ };
+
+ if results.is_empty() {
+ FilterState::needs_remote()
+ } else {
+ let kind = 1;
+ let notes_per_pk = 1;
+ match filter::last_n_per_pubkey_from_tags(&results[0].note, kind, notes_per_pk) {
+ Err(notedeck::Error::Filter(FilterError::EmptyContactList)) => {
+ FilterState::needs_remote()
+ }
+ Err(err) => {
+ error!("Error getting people list filter state: {err}");
+ FilterState::Broken(FilterError::EmptyList)
+ }
+ Ok(filter) => FilterState::ready(filter),
+ }
+ }
+}
diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs
@@ -2,7 +2,11 @@ use crate::{
error::Error,
multi_subscriber::TimelineSub,
subscriptions::{self, SubKind, Subscriptions},
- timeline::{kind::ListKind, note_units::InsertManyResponse, timeline_units::NotePayload},
+ timeline::{
+ kind::{people_list_note_filter, AlgoTimeline, ListKind, PeopleListRef},
+ note_units::InsertManyResponse,
+ timeline_units::NotePayload,
+ },
Result,
};
@@ -279,7 +283,7 @@ impl Timeline {
let filter = filter::last_n_per_pubkey_from_tags(list, kind, notes_per_pk)?;
Ok(Timeline::new(
- TimelineKind::last_per_pubkey(*list_kind),
+ TimelineKind::last_per_pubkey(list_kind.clone()),
FilterState::ready(filter),
TimelineTab::only_notes_and_replies(),
))
@@ -722,7 +726,20 @@ pub fn send_initial_timeline_filter(
}
// we need some data first
- FilterState::NeedsRemote => fetch_contact_list(subs, timeline, accounts),
+ FilterState::NeedsRemote => {
+ let people_list_ref = match &timeline.kind {
+ TimelineKind::List(ListKind::PeopleList(plr))
+ | TimelineKind::Algo(AlgoTimeline::LastPerPubkey(ListKind::PeopleList(plr))) => {
+ Some(plr.clone())
+ }
+ _ => None,
+ };
+ if let Some(plr) = people_list_ref {
+ fetch_people_list(subs, relay, timeline, &plr);
+ } else {
+ fetch_contact_list(subs, timeline, accounts);
+ }
+ }
}
}
@@ -753,6 +770,33 @@ pub fn fetch_contact_list(subs: &mut Subscriptions, timeline: &mut Timeline, acc
subs.subs.insert(sub.remote.clone(), sub_kind);
}
+pub fn fetch_people_list(
+ subs: &mut Subscriptions,
+ relay: &mut PoolRelay,
+ timeline: &mut Timeline,
+ plr: &PeopleListRef,
+) {
+ if timeline.filter.get_any_ready().is_some() {
+ return;
+ }
+
+ let filter = people_list_note_filter(plr);
+ let sub_id = subscriptions::new_sub_id();
+
+ if let Err(err) = relay.subscribe(sub_id.clone(), vec![filter]) {
+ error!("error subscribing for people list: {err}");
+ return;
+ }
+
+ timeline.filter.set_relay_state(
+ relay.url().to_string(),
+ FilterState::FetchingRemote(filter::FetchingRemoteType::PeopleList),
+ );
+
+ let sub_kind = SubKind::FetchingContactList(timeline.kind.clone());
+ subs.subs.insert(sub_id, sub_kind);
+}
+
#[profiling::function]
fn setup_initial_timeline(
ndb: &Ndb,
@@ -890,6 +934,36 @@ pub fn is_timeline_ready(
(relay_id, *note_key)
}
+ filter::GotRemoteResult::PeopleList { relay_id } => {
+ // Query ndb directly for the kind 30000 note. It should
+ // have been ingested from the relay by now.
+ let plr = match &timeline.kind {
+ TimelineKind::List(ListKind::PeopleList(plr))
+ | TimelineKind::Algo(AlgoTimeline::LastPerPubkey(ListKind::PeopleList(plr))) => plr,
+ _ => {
+ error!("GotRemoteResult::PeopleList but timeline kind is not PeopleList");
+ return false;
+ }
+ };
+
+ let list_filter = people_list_note_filter(plr);
+ let txn = Transaction::new(ndb).expect("txn");
+ let results = match ndb.query(&txn, std::slice::from_ref(&list_filter), 1) {
+ Ok(results) => results,
+ Err(err) => {
+ error!("people list query failed in is_timeline_ready: {err}");
+ return false;
+ }
+ };
+
+ if results.is_empty() {
+ debug!("people list note not yet in ndb for {:?}", plr);
+ return false;
+ }
+
+ info!("found people list note after GotRemote!");
+ (relay_id, results[0].note_key)
+ }
};
let with_hashtags = false;
@@ -915,7 +989,7 @@ pub fn is_timeline_ready(
error!("got broken when building filter {err}");
timeline
.filter
- .set_relay_state(relay_id, FilterState::broken(FilterError::EmptyContactList));
+ .set_relay_state(relay_id, FilterState::broken(FilterError::EmptyList));
false
}
Ok(filter) => {
diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs
@@ -6,7 +6,7 @@ use egui::{
Separator, Ui, Vec2, Widget,
};
use enostr::Pubkey;
-use nostrdb::{Ndb, Transaction};
+use nostrdb::{Filter, Ndb, Transaction};
use tracing::error;
use crate::{
@@ -16,7 +16,6 @@ use crate::{
timeline::{kind::ListKind, PubkeySource, TimelineKind},
Damus,
};
-
use notedeck::{
tr, AppContext, ContactState, Images, Localization, MediaJobSender, NotedeckTextStyle,
UserAccount,
@@ -38,6 +37,7 @@ pub enum AddColumnResponse {
Algo(AlgoOption),
UndecidedIndividual,
ExternalIndividual,
+ PeopleList,
}
struct SelectionHandler<'a> {
@@ -79,6 +79,7 @@ enum AddColumnOption {
UndecidedIndividual,
ExternalIndividual,
Individual(PubkeySource),
+ UndecidedPeopleList,
}
#[derive(Clone, Copy, Eq, PartialEq, Debug, Default, Hash)]
@@ -97,6 +98,7 @@ pub enum AddColumnRoute {
Algo(AddAlgoRoute),
UndecidedIndividual,
ExternalIndividual,
+ PeopleList,
}
// Parser for the common case without any payloads
@@ -125,7 +127,9 @@ impl AddColumnRoute {
Self::Algo(AddAlgoRoute::Base) => &["column", "algo_selection"],
Self::Algo(AddAlgoRoute::LastPerPubkey) => {
&["column", "algo_selection", "last_per_pubkey"]
- } // NOTE!!! When adding to this, update the parser for TokenSerializable below
+ }
+ Self::PeopleList => &["column", "people_list"],
+ // NOTE!!! When adding to this, update the parser for TokenSerializable below
}
}
}
@@ -151,6 +155,7 @@ impl TokenSerializable for AddColumnRoute {
|p| parse_column_route(p, AddColumnRoute::Hashtag),
|p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::Base)),
|p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey)),
+ |p| parse_column_route(p, AddColumnRoute::PeopleList),
],
)
}
@@ -175,6 +180,7 @@ impl AddColumnOption {
AddColumnOption::Individual(pubkey_source) => AddColumnResponse::Timeline(
TimelineKind::profile(*pubkey_source.as_pubkey(&cur_account.key.pubkey)),
),
+ AddColumnOption::UndecidedPeopleList => AddColumnResponse::PeopleList,
}
}
}
@@ -188,6 +194,9 @@ pub struct AddColumnView<'a> {
contacts: &'a ContactState,
i18n: &'a mut Localization,
jobs: &'a MediaJobSender,
+ pool: &'a mut enostr::RelayPool,
+ unknown_ids: &'a mut notedeck::UnknownIds,
+ people_lists: &'a mut Option<notedeck::Nip51SetCache>,
}
impl<'a> AddColumnView<'a> {
@@ -201,6 +210,9 @@ impl<'a> AddColumnView<'a> {
contacts: &'a ContactState,
i18n: &'a mut Localization,
jobs: &'a MediaJobSender,
+ pool: &'a mut enostr::RelayPool,
+ unknown_ids: &'a mut notedeck::UnknownIds,
+ people_lists: &'a mut Option<notedeck::Nip51SetCache>,
) -> Self {
Self {
key_state_map,
@@ -211,6 +223,9 @@ impl<'a> AddColumnView<'a> {
contacts,
i18n,
jobs,
+ pool,
+ unknown_ids,
+ people_lists,
}
}
@@ -279,6 +294,60 @@ impl<'a> AddColumnView<'a> {
.then(|| option.take_as_response(self.cur_account))
}
+ fn people_list_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
+ // Initialize the cache on first visit — subscribes locally and to relays
+ if self.people_lists.is_none() {
+ let txn = Transaction::new(self.ndb).expect("txn");
+ let filter = Filter::new()
+ .authors([self.cur_account.key.pubkey.bytes()])
+ .kinds([30000])
+ .limit(50)
+ .build();
+ *self.people_lists = notedeck::Nip51SetCache::new(
+ self.pool,
+ self.ndb,
+ &txn,
+ self.unknown_ids,
+ vec![filter],
+ );
+ }
+
+ // Poll for newly arrived notes each frame
+ if let Some(cache) = self.people_lists.as_mut() {
+ cache.poll_for_notes(self.ndb, self.unknown_ids);
+ }
+
+ padding(16.0, ui, |ui| {
+ let Some(cache) = self.people_lists.as_ref() else {
+ ui.label("Loading lists from relays...");
+ return None;
+ };
+
+ if cache.is_empty() {
+ ui.label("Loading lists from relays...");
+ return None;
+ }
+
+ let mut response = None;
+ for set in cache.iter() {
+ let title = set.title.as_deref().unwrap_or(&set.identifier);
+ let label = format!("{} ({} members)", title, set.pks.len());
+
+ if ui.button(&label).clicked() {
+ response = Some(AddColumnResponse::Timeline(TimelineKind::people_list(
+ self.cur_account.key.pubkey,
+ set.identifier.clone(),
+ )));
+ }
+
+ ui.add(Separator::default().spacing(4.0));
+ }
+
+ response
+ })
+ .inner
+ }
+
fn algo_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
let algo_option = ColumnOptionData {
title: tr!(
@@ -544,6 +613,16 @@ impl<'a> AddColumnView<'a> {
option: AddColumnOption::UndecidedIndividual,
});
vec.push(ColumnOptionData {
+ title: tr!(self.i18n, "People List", "Title for people list column"),
+ description: tr!(
+ self.i18n,
+ "See notes from a NIP-51 people list",
+ "Description for people list column"
+ ),
+ icon: app_images::home_image(),
+ option: AddColumnOption::UndecidedPeopleList,
+ });
+ vec.push(ColumnOptionData {
title: tr!(self.i18n, "Algo", "Title for algorithmic feeds column"),
description: tr!(
self.i18n,
@@ -817,6 +896,9 @@ pub fn render_add_column_routes(
contacts,
ctx.i18n,
ctx.media_jobs.sender(),
+ ctx.pool,
+ ctx.unknown_ids,
+ &mut app.view_state.people_lists,
);
match route {
AddColumnRoute::Base => add_column_view.ui(ui),
@@ -831,6 +913,7 @@ pub fn render_add_column_routes(
AddColumnRoute::Hashtag => unreachable!(),
AddColumnRoute::UndecidedIndividual => add_column_view.individual_ui(ui),
AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui),
+ AddColumnRoute::PeopleList => add_column_view.people_list_ui(ui),
}
};
@@ -883,8 +966,8 @@ pub fn render_add_column_routes(
// add it to our list of timelines
AlgoOption::LastPerPubkey(Decision::Decided(list_kind)) => {
let txn = Transaction::new(ctx.ndb).unwrap();
- let maybe_timeline =
- TimelineKind::last_per_pubkey(list_kind).into_timeline(&txn, ctx.ndb);
+ let maybe_timeline = TimelineKind::last_per_pubkey(list_kind.clone())
+ .into_timeline(&txn, ctx.ndb);
if let Some(mut timeline) = maybe_timeline {
crate::timeline::setup_new_timeline(
@@ -952,6 +1035,12 @@ pub fn render_add_column_routes(
AddColumnRoute::ExternalIndividual,
));
}
+ AddColumnResponse::PeopleList => {
+ app.columns_mut(ctx.i18n, ctx.accounts)
+ .column_mut(col)
+ .router_mut()
+ .route_to(crate::route::Route::AddColumn(AddColumnRoute::PeopleList));
+ }
};
}
}
diff --git a/crates/notedeck_columns/src/view_state.rs b/crates/notedeck_columns/src/view_state.rs
@@ -2,6 +2,7 @@ use std::collections::HashMap;
use enostr::Pubkey;
use notedeck::compact::CompactState;
+use notedeck::Nip51SetCache;
use notedeck::ReportType;
use notedeck_ui::nip51_set::Nip51SetUiCache;
@@ -41,6 +42,9 @@ pub struct ViewState {
/// Database compaction state
pub compact: CompactState,
+
+ /// Cache for people list selection in "Add Column" UI
+ pub people_lists: Option<Nip51SetCache>,
}
impl ViewState {