notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
Mcrates/notedeck/src/error.rs | 3+++
Mcrates/notedeck/src/filter.rs | 8++++++++
Mcrates/notedeck_columns/src/app.rs | 32++++++++++++++++++++++++++++++--
Mcrates/notedeck_columns/src/route.rs | 5+++++
Mcrates/notedeck_columns/src/timeline/kind.rs | 182++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/notedeck_columns/src/timeline/mod.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/notedeck_columns/src/ui/add_column.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/notedeck_columns/src/view_state.rs | 4++++
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 {