notedeck

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

commit e22a1030de64335124ed26565fdf739321ae3efa
parent cae6418472ffda09ae5e2cb41a4736e04812bc3b
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 26 Feb 2026 12:03:58 -0800

columns: add people list creation UI

Add the ability to create NIP-51 people lists (kind 30000) from the
"Add Column > People List" flow. Previously this screen only loaded
existing lists from relays, which was useless if the user had none.

- Add send_people_list_event() to publish kind 30000 events with d,
  title, and p tags
- Add "New List" button to people list selection screen
- Add CreatePeopleList route with name input, profile search, and
  contact selection with checkboxes
- After creation, the event is published and a timeline column is
  immediately added for the new list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck/src/lib.rs | 6+++---
Mcrates/notedeck/src/note/mod.rs | 4++--
Mcrates/notedeck/src/note/publish.rs | 25+++++++++++++++++++++++++
Mcrates/notedeck_columns/src/route.rs | 5+++++
Mcrates/notedeck_columns/src/ui/add_column.rs | 333+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/notedeck_columns/src/view_state.rs | 10+++++++++-
6 files changed, 344 insertions(+), 39 deletions(-)

diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -76,9 +76,9 @@ pub use nav::DragResponse; pub use nip05::{Nip05Cache, Nip05Status}; pub use nip51_set::{create_nip51_set, Nip51Set, Nip51SetCache}; pub use note::{ - builder_from_note, get_p_tags, send_mute_event, send_note_builder, send_report_event, - send_unmute_event, BroadcastContext, ContextSelection, NoteAction, NoteContext, - NoteContextSelection, NoteRef, ReportTarget, ReportType, RootIdError, RootNoteId, + builder_from_note, get_p_tags, send_mute_event, send_note_builder, send_people_list_event, + send_report_event, send_unmute_event, BroadcastContext, ContextSelection, NoteAction, + NoteContext, NoteContextSelection, NoteRef, ReportTarget, ReportType, RootIdError, RootNoteId, RootNoteIdBuf, ScrollInfo, ZapAction, }; pub use notecache::{CachedNote, NoteCache}; diff --git a/crates/notedeck/src/note/mod.rs b/crates/notedeck/src/note/mod.rs @@ -5,8 +5,8 @@ pub mod publish; pub use action::{NoteAction, ReactAction, ScrollInfo, ZapAction, ZapTargetAmount}; pub use context::{BroadcastContext, ContextSelection, NoteContextSelection}; pub use publish::{ - builder_from_note, send_mute_event, send_note_builder, send_report_event, send_unmute_event, - ReportTarget, ReportType, + builder_from_note, send_mute_event, send_note_builder, send_people_list_event, + send_report_event, send_unmute_event, ReportTarget, ReportType, }; use crate::jobs::MediaJobSender; diff --git a/crates/notedeck/src/note/publish.rs b/crates/notedeck/src/note/publish.rs @@ -203,6 +203,31 @@ pub fn send_mute_event( send_note_builder(builder, ndb, pool, kp); } +pub fn send_people_list_event( + ndb: &Ndb, + pool: &mut RelayPool, + kp: FilledKeypair, + name: &str, + members: &[Pubkey], +) { + let mut builder = NoteBuilder::new() + .content("") + .kind(30000) + .options(NoteBuildOptions::default()) + .start_tag() + .tag_str("d") + .tag_str(name) + .start_tag() + .tag_str("title") + .tag_str(name); + + for pk in members { + builder = builder.start_tag().tag_str("p").tag_str(&pk.hex()); + } + + send_note_builder(builder, ndb, pool, kp); +} + pub fn send_report_event( ndb: &Ndb, pool: &mut RelayPool, diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs @@ -419,6 +419,11 @@ impl Route { "Select a People List", "Column title for selecting a people list" )), + AddColumnRoute::CreatePeopleList => ColumnTitle::formatted(tr!( + i18n, + "Create People List", + "Column title for creating a people list" + )), }, Route::Support => { ColumnTitle::formatted(tr!(i18n, "Damus Support", "Column title for support page")) diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs @@ -38,6 +38,8 @@ pub enum AddColumnResponse { UndecidedIndividual, ExternalIndividual, PeopleList, + CreatePeopleList, + FinishCreatePeopleList, } struct SelectionHandler<'a> { @@ -99,6 +101,7 @@ pub enum AddColumnRoute { UndecidedIndividual, ExternalIndividual, PeopleList, + CreatePeopleList, } // Parser for the common case without any payloads @@ -129,6 +132,7 @@ impl AddColumnRoute { &["column", "algo_selection", "last_per_pubkey"] } Self::PeopleList => &["column", "people_list"], + Self::CreatePeopleList => &["column", "create_people_list"], // NOTE!!! When adding to this, update the parser for TokenSerializable below } } @@ -156,6 +160,7 @@ impl TokenSerializable for AddColumnRoute { |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), + |p| parse_column_route(p, AddColumnRoute::CreatePeopleList), ], ) } @@ -318,13 +323,20 @@ impl<'a> AddColumnView<'a> { } padding(16.0, ui, |ui| { + // Always show "New List" button at the top + if ui.button("+ New List").clicked() { + return Some(AddColumnResponse::CreatePeopleList); + } + + ui.add_space(8.0); + 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..."); + ui.label("No people lists found."); return None; } @@ -881,39 +893,44 @@ pub fn render_add_column_routes( col: usize, route: &AddColumnRoute, ) { - // Handle hashtag separately since it borrows id_string_map directly - let resp = if matches!(route, AddColumnRoute::Hashtag) { - hashtag_ui(ui, ctx.i18n, &mut app.view_state.id_string_map) - } else { - let account = ctx.accounts.get_selected_account(); - let contacts = account.data.contacts.get_state(); - let mut add_column_view = AddColumnView::new( - &mut app.view_state.id_state_map, - &mut app.view_state.id_string_map, - ctx.ndb, - ctx.img_cache, - account, - 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), - AddColumnRoute::Algo(r) => match r { - AddAlgoRoute::Base => add_column_view.algo_ui(ui), - AddAlgoRoute::LastPerPubkey => { - add_column_view.algo_last_per_pk_ui(ui, account.key.pubkey) + // Hashtag and CreatePeopleList are handled separately because they + // borrow ViewState fields directly (conflicting with AddColumnView) + let resp = match route { + AddColumnRoute::Hashtag => hashtag_ui(ui, ctx.i18n, &mut app.view_state.id_string_map), + AddColumnRoute::CreatePeopleList => create_people_list_ui(ui, app, ctx), + _ => { + let account = ctx.accounts.get_selected_account(); + let contacts = account.data.contacts.get_state(); + let mut add_column_view = AddColumnView::new( + &mut app.view_state.id_state_map, + &mut app.view_state.id_string_map, + ctx.ndb, + ctx.img_cache, + account, + 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), + AddColumnRoute::Algo(r) => match r { + AddAlgoRoute::Base => add_column_view.algo_ui(ui), + AddAlgoRoute::LastPerPubkey => { + add_column_view.algo_last_per_pk_ui(ui, account.key.pubkey) + } + }, + AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), + AddColumnRoute::ExternalNotification => { + add_column_view.external_notification_ui(ui) } - }, - AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), - AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui), - 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), + 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), + AddColumnRoute::Hashtag | AddColumnRoute::CreatePeopleList => unreachable!(), + } } }; @@ -1041,10 +1058,91 @@ pub fn render_add_column_routes( .router_mut() .route_to(crate::route::Route::AddColumn(AddColumnRoute::PeopleList)); } + AddColumnResponse::CreatePeopleList => { + app.columns_mut(ctx.i18n, ctx.accounts) + .column_mut(col) + .router_mut() + .route_to(crate::route::Route::AddColumn( + AddColumnRoute::CreatePeopleList, + )); + } + AddColumnResponse::FinishCreatePeopleList => { + handle_create_people_list(app, ctx, col); + } }; } } +fn handle_create_people_list(app: &mut Damus, ctx: &mut AppContext<'_>, col: usize) { + let name_id = Id::new("create_people_list_name"); + let name = app + .view_state + .id_string_map + .get(&name_id) + .cloned() + .unwrap_or_default(); + + if name.is_empty() { + return; + } + + let members: Vec<Pubkey> = app + .view_state + .create_people_list + .selected_members + .iter() + .copied() + .collect(); + + if members.is_empty() { + return; + } + + let Some(kp) = ctx.accounts.selected_filled() else { + error!("Cannot create people list: no signing key available"); + return; + }; + + notedeck::send_people_list_event(ctx.ndb, ctx.pool, kp, &name, &members); + + // Reset the people_lists cache so it picks up the new list + app.view_state.people_lists = None; + + // Clear creation state + app.view_state.id_string_map.remove(&name_id); + let search_id = Id::new("create_people_list_search"); + app.view_state.id_string_map.remove(&search_id); + app.view_state.create_people_list.selected_members.clear(); + + // Create the timeline column immediately + let pubkey = ctx.accounts.get_selected_account().key.pubkey; + let timeline_kind = TimelineKind::people_list(pubkey, name); + let txn = Transaction::new(ctx.ndb).unwrap(); + let Some(mut timeline) = timeline_kind.into_timeline(&txn, ctx.ndb) else { + error!("Could not create timeline from people list"); + return; + }; + + crate::timeline::setup_new_timeline( + &mut timeline, + ctx.ndb, + &txn, + &mut app.subscriptions, + ctx.pool, + ctx.note_cache, + app.options.contains(AppOptions::SinceOptimize), + ctx.accounts, + ctx.unknown_ids, + ); + + app.columns_mut(ctx.i18n, ctx.accounts) + .column_mut(col) + .router_mut() + .route_to_replaced(Route::timeline(timeline.kind.clone())); + + app.timeline_cache.insert(timeline.kind.clone(), timeline); +} + pub fn hashtag_ui( ui: &mut Ui, i18n: &mut Localization, @@ -1097,6 +1195,175 @@ pub fn hashtag_ui( .inner } +pub fn create_people_list_ui( + ui: &mut Ui, + app: &mut Damus, + ctx: &mut AppContext<'_>, +) -> Option<AddColumnResponse> { + let account = ctx.accounts.get_selected_account(); + let contacts = account.data.contacts.get_state(); + + padding(16.0, ui, |ui| { + // Use Id::new so IDs are stable across UI contexts (not dependent on parent widget) + let name_id = Id::new("create_people_list_name"); + let name_buffer = app.view_state.id_string_map.entry(name_id).or_default(); + + ui.label(RichText::new("List Name").text_style(NotedeckTextStyle::Body.text_style())); + ui.add_space(4.0); + let name_edit = egui::TextEdit::singleline(name_buffer) + .hint_text( + RichText::new("Enter list name...") + .text_style(NotedeckTextStyle::Body.text_style()), + ) + .vertical_align(Align::Center) + .desired_width(f32::INFINITY) + .min_size(Vec2::new(0.0, 40.0)) + .margin(Margin::same(12)); + ui.add(name_edit); + + ui.add_space(8.0); + + // Selected members count + let member_count = app.view_state.create_people_list.selected_members.len(); + ui.label( + RichText::new(format!("{} members selected", member_count)) + .text_style(NotedeckTextStyle::Body.text_style()) + .weak(), + ); + + ui.add_space(8.0); + + // Search bar + let search_id = Id::new("create_people_list_search"); + let search_buffer = app.view_state.id_string_map.entry(search_id).or_default(); + + ui.add(search_input_box(search_buffer, "Search profiles...")); + + ui.add_space(8.0); + + // Profile results area + let txn = Transaction::new(ctx.ndb).expect("txn"); + let search_query = app + .view_state + .id_string_map + .get(&search_id) + .cloned() + .unwrap_or_default(); + + ScrollArea::vertical().show(ui, |ui| { + if search_query.is_empty() { + // Show contacts + if let ContactState::Received { + contacts: contact_set, + .. + } = contacts + { + for pk in contact_set { + let profile = ctx.ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok(); + let is_selected = app + .view_state + .create_people_list + .selected_members + .contains(pk); + + ui.horizontal(|ui| { + let mut checked = is_selected; + ui.checkbox(&mut checked, ""); + let clicked = profile_row( + ui, + profile.as_ref(), + false, + ctx.img_cache, + ctx.media_jobs.sender(), + ctx.i18n, + ); + if clicked || checked != is_selected { + if is_selected { + app.view_state + .create_people_list + .selected_members + .remove(pk); + } else { + app.view_state + .create_people_list + .selected_members + .insert(*pk); + } + } + }); + } + } else { + ui.label(RichText::new("No contacts loaded yet.").weak()); + } + } else { + // Show search results + let results = search_profiles(ctx.ndb, &txn, &search_query, contacts, 128); + + if results.is_empty() { + ui.add_space(20.0); + ui.label(RichText::new("No profiles found").weak()); + } else { + for result in &results { + let pk = Pubkey::new(result.pk); + let profile = ctx.ndb.get_profile_by_pubkey(&txn, &result.pk).ok(); + let is_selected = app + .view_state + .create_people_list + .selected_members + .contains(&pk); + + ui.horizontal(|ui| { + let mut checked = is_selected; + ui.checkbox(&mut checked, ""); + let clicked = profile_row( + ui, + profile.as_ref(), + result.is_contact, + ctx.img_cache, + ctx.media_jobs.sender(), + ctx.i18n, + ); + if clicked || checked != is_selected { + if is_selected { + app.view_state + .create_people_list + .selected_members + .remove(&pk); + } else { + app.view_state + .create_people_list + .selected_members + .insert(pk); + } + } + }); + } + } + } + }); + + ui.add_space(8.0); + + // Create button + let name_text = app + .view_state + .id_string_map + .get(&name_id) + .cloned() + .unwrap_or_default(); + let can_create = !name_text.is_empty() && member_count > 0; + + let create_btn = egui::Button::new("Create List"); + let resp = ui.add_enabled(can_create, create_btn); + if resp.clicked() { + return Some(AddColumnResponse::FinishCreatePeopleList); + } + + None + }) + .inner +} + fn sanitize_hashtag(raw_hashtag: &str) -> String { raw_hashtag .chars() diff --git a/crates/notedeck_columns/src/view_state.rs b/crates/notedeck_columns/src/view_state.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use enostr::Pubkey; use notedeck::compact::CompactState; @@ -45,6 +45,14 @@ pub struct ViewState { /// Cache for people list selection in "Add Column" UI pub people_lists: Option<Nip51SetCache>, + + /// State for the "Create People List" flow + pub create_people_list: CreatePeopleListState, +} + +#[derive(Default)] +pub struct CreatePeopleListState { + pub selected_members: HashSet<Pubkey>, } impl ViewState {