notedeck

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

commit 179cc9f446437ddeb73033228ea98b4065a2de55
parent 38df08bc2fdf90fff213fe75c182b629f6b73c14
Author: William Casarin <jb55@jb55.com>
Date:   Mon,  5 Jan 2026 14:29:29 -0800

Merge collapsible follow pack members by jb55&elsat #1200

William Casarin (9):
      nip51: refactor and simplify ui collapse logic
      nip51: weak text color for about
      nip51: route card clicks to checkbox
      nip51: add docstring for select_all_ui
      nip51: pluralize collapse header
      nip51: add docstring for entry_for_pack
      nip51: add docstring for apply_select_all_to_pack
      nip51: add docstring for RenderPackResponse
      Merge collapsible follow pack members by jb55&elsat #1200

alltheseas (1):
      Add collapsible follow pack members

Diffstat:
Mcrates/notedeck_columns/src/ui/onboarding.rs | 11+++++++++++
Mcrates/notedeck_ui/src/nip51_set.rs | 226++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
2 files changed, 149 insertions(+), 88 deletions(-)

diff --git a/crates/notedeck_columns/src/ui/onboarding.rs b/crates/notedeck_columns/src/ui/onboarding.rs @@ -63,6 +63,7 @@ impl<'a> FollowPackOnboardingView<'a> { let max_height = ui.available_height() - 48.0; let mut action = None; + let mut should_reset_list = false; let scroll_out = ScrollArea::vertical() .id_salt(Self::scroll_id()) .max_height(max_height) @@ -83,6 +84,8 @@ impl<'a> FollowPackOnboardingView<'a> { .with_flags(Nip51SetWidgetFlags::TRUST_IMAGES) .render_at_index(ui, index); + notedeck_ui::hline(ui); + if let Some(cur_action) = resp.action { match cur_action { Nip51SetWidgetAction::ViewProfile(pubkey) => { @@ -91,6 +94,10 @@ impl<'a> FollowPackOnboardingView<'a> { } } + if resp.visibility_changed { + should_reset_list = true; + } + if resp.rendered { 1 } else { @@ -101,6 +108,10 @@ impl<'a> FollowPackOnboardingView<'a> { }) }); + if should_reset_list { + self.onboarding.list.borrow_mut().reset(); + } + ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { ui.add_space(4.0); if ui.add(styled_button(tr!(self.loc, "Done", "Button to indicate that the user is done going through the onboarding process.").as_str(), colors::PINK)).clicked() { diff --git a/crates/notedeck_ui/src/nip51_set.rs b/crates/notedeck_ui/src/nip51_set.rs @@ -1,11 +1,11 @@ use bitflags::bitflags; -use egui::{vec2, Checkbox, CornerRadius, Layout, Margin, RichText, Sense, UiBuilder}; +use egui::{vec2, Checkbox, CornerRadius, Margin, RichText, Sense, UiBuilder}; use enostr::Pubkey; use hashbrown::{hash_map::RawEntryMut, HashMap}; use nostrdb::{Ndb, ProfileRecord, Transaction}; use notedeck::{ - fonts::get_font_size, get_profile_url, name::get_display_name, tr, Images, Localization, - MediaJobSender, Nip51Set, Nip51SetCache, NotedeckTextStyle, + fonts::get_font_size, get_profile_url, name::get_display_name, tr, tr_plural, Images, + Localization, MediaJobSender, Nip51Set, Nip51SetCache, NotedeckTextStyle, }; use crate::{ @@ -75,10 +75,11 @@ impl<'a> Nip51SetWidget<'a> { return Nip51SetWidgetResponse { action: None, rendered: false, + visibility_changed: false, }; } - let action = egui::Frame::new() + let pack_resp = egui::Frame::new() .corner_radius(CornerRadius::same(8)) //.fill(ui.visuals().extreme_bg_color) .inner_margin(Margin::same(8)) @@ -97,8 +98,9 @@ impl<'a> Nip51SetWidget<'a> { .inner; Nip51SetWidgetResponse { - action, + action: pack_resp.action, rendered: true, + visibility_changed: pack_resp.visibility_changed, } } @@ -107,6 +109,7 @@ impl<'a> Nip51SetWidget<'a> { return Nip51SetWidgetResponse { action: None, rendered: false, + visibility_changed: false, }; }; @@ -136,6 +139,7 @@ impl<'a> Nip51SetWidget<'a> { pub struct Nip51SetWidgetResponse { pub action: Option<Nip51SetWidgetAction>, pub rendered: bool, + pub visibility_changed: bool, } fn should_skip(set: &Nip51Set, required: &Nip51SetWidgetFlags) -> bool { @@ -146,6 +150,45 @@ fn should_skip(set: &Nip51Set, required: &Nip51SetWidgetFlags) -> bool { || (required.contains(Nip51SetWidgetFlags::NON_EMPTY_PKS) && set.pks.is_empty()) } +/// Internal response type from rendering a follow pack. Tracks user actions and whether the +/// pack's visibility state changed. +struct RenderPackResponse { + action: Option<Nip51SetWidgetAction>, + visibility_changed: bool, +} + +/// Renders a "Select All" checkbox for a follow pack. +/// When toggled, applies the selection state to all profiles in the pack. +fn select_all_ui( + pack: &Nip51Set, + ui_state: &mut Nip51SetUiCache, + loc: &mut Localization, + ui: &mut egui::Ui, +) { + let select_all_resp = ui.checkbox( + ui_state.get_select_all_state(&pack.identifier), + format!( + "{} ({})", + tr!( + loc, + "Select All", + "Button to select all profiles in follow pack" + ), + pack.pks.len() + ), + ); + + let new_select_all_state = if select_all_resp.clicked() { + Some(*ui_state.get_select_all_state(&pack.identifier)) + } else { + None + }; + + if let Some(use_state) = new_select_all_state { + ui_state.apply_select_all_to_pack(&pack.identifier, &pack.pks, use_state); + } +} + #[allow(clippy::too_many_arguments)] fn render_pack( ui: &mut egui::Ui, @@ -156,7 +199,7 @@ fn render_pack( jobs: &MediaJobSender, loc: &mut Localization, image_trusted: bool, -) -> Option<Nip51SetWidgetAction> { +) -> RenderPackResponse { let max_img_size = vec2(ui.available_width(), 200.0); ui.allocate_new_ui(UiBuilder::new(), |ui| 's: { @@ -184,72 +227,61 @@ fn render_pack( ui.advance_cursor_after_rect(media_rect); }); - let (title_rect, _) = - ui.allocate_at_least(vec2(ui.available_width(), 0.0), egui::Sense::hover()); - - let select_all_resp = ui - .allocate_new_ui( - UiBuilder::new() - .max_rect(title_rect) - .layout(Layout::top_down(egui::Align::Min)), - |ui| { - if let Some(title) = &pack.title { - ui.add(egui::Label::new(egui::RichText::new(title).size( - get_font_size(ui.ctx(), &notedeck::NotedeckTextStyle::Heading), - ))); - } - if let Some(desc) = &pack.description { - ui.add(egui::Label::new( - egui::RichText::new(desc) - .size(get_font_size( - ui.ctx(), - &notedeck::NotedeckTextStyle::Heading3, - )) - .color(ui.visuals().weak_text_color()), - )); - } - let checked = ui.checkbox( - ui_state.get_select_all_state(&pack.identifier), - format!( - "{} ({})", - tr!( - loc, - "Select All", - "Button to select all profiles in follow pack" - ), - pack.pks.len() - ), - ); - - checked - }, - ) - .inner; + ui.add_space(4.0); - let new_select_all_state = if select_all_resp.clicked() { - Some(*ui_state.get_select_all_state(&pack.identifier)) - } else { - None - }; + let mut action = None; - let mut resp = None; - let txn = Transaction::new(ndb).expect("txn"); + if let Some(title) = &pack.title { + ui.add(egui::Label::new(egui::RichText::new(title).size( + get_font_size(ui.ctx(), &notedeck::NotedeckTextStyle::Heading), + ))); + } - for pk in &pack.pks { - let m_profile = ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok(); + if let Some(desc) = &pack.description { + ui.add(egui::Label::new( + egui::RichText::new(desc) + .size(get_font_size( + ui.ctx(), + &notedeck::NotedeckTextStyle::Heading3, + )) + .color(ui.visuals().weak_text_color()), + )); + } - let cur_state = ui_state.get_pk_selected_state(&pack.identifier, pk); - if let Some(use_state) = new_select_all_state { - *cur_state = use_state; - }; + let pack_len = pack.pks.len(); + let default_open = pack_len < 6; + + let r = egui::CollapsingHeader::new(tr_plural!( + loc, + "{pack_len} person", + "{pack_len} people", + "Label showing count of people in a follow pack", + pack_len, + )) + .default_open(default_open) + .show(ui, |ui| { + select_all_ui(pack, ui_state, loc, ui); + + let txn = Transaction::new(ndb).expect("txn"); - ui.separator(); - if render_profile_item(ui, images, jobs, m_profile.as_ref(), cur_state) { - resp = Some(Nip51SetWidgetAction::ViewProfile(*pk)); + for pk in &pack.pks { + let m_profile = ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok(); + + let cur_state = ui_state.get_pk_selected_state(&pack.identifier, pk); + + crate::hline(ui); + + if render_profile_item(ui, images, jobs, m_profile.as_ref(), cur_state).clicked() { + action = Some(Nip51SetWidgetAction::ViewProfile(*pk)); + } } - } + }); - resp + let visibility_changed = r.header_response.clicked(); + RenderPackResponse { + action, + visibility_changed, + } } const PFP_SIZE: f32 = 32.0; @@ -260,7 +292,7 @@ fn render_profile_item( jobs: &MediaJobSender, profile: Option<&ProfileRecord>, checked: &mut bool, -) -> bool { +) -> egui::Response { let (card_rect, card_resp) = ui.allocate_exact_size(vec2(ui.available_width(), PFP_SIZE), egui::Sense::click()); @@ -287,15 +319,16 @@ fn render_profile_item( let (pfp_rect, body_rect) = remaining_rect.split_left_right_at_x(remaining_rect.left() + PFP_SIZE); - let _ = ui.allocate_new_ui(UiBuilder::new().max_rect(pfp_rect), |ui| { - let pfp_resp = ui.add( - &mut ProfilePic::new(images, jobs, get_profile_url(profile)) - .sense(Sense::click()) - .size(PFP_SIZE), - ); + let pfp_response = ui + .allocate_new_ui(UiBuilder::new().max_rect(pfp_rect), |ui| { + ui.add( + &mut ProfilePic::new(images, jobs, get_profile_url(profile)) + .sense(Sense::click()) + .size(PFP_SIZE), + ) + }) + .inner; - clicked_response = clicked_response.union(pfp_resp); - }); ui.advance_cursor_after_rect(pfp_rect); let (_, body_rect) = body_rect.split_left_right_at_x(body_rect.left() + 8.0); @@ -324,7 +357,7 @@ fn render_profile_item( if let Some(username) = name.username { let galley = painter.layout_no_wrap( format!("@{username}"), - NotedeckTextStyle::Body.get_font_id(ui.ctx()), + NotedeckTextStyle::Small.get_font_id(ui.ctx()), crate::colors::MID_GRAY, ); @@ -334,7 +367,7 @@ fn render_profile_item( let padding = name_rect.height() - galley.rect.height(); - pos.y += padding / 2.0; + pos.y += padding * 2.5; pos }; @@ -342,6 +375,7 @@ fn render_profile_item( } }); ui.advance_cursor_after_rect(name_rect); + clicked_response = clicked_response.union(resp.response); let resp = ui.allocate_new_ui(UiBuilder::new().max_rect(description_rect), |ui| 's: { @@ -359,7 +393,9 @@ fn render_profile_item( ui.add( egui::Label::new( - RichText::new(about).size(get_font_size(ui.ctx(), &NotedeckTextStyle::Heading4)), + RichText::new(about) + .color(ui.visuals().weak_text_color()) + .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Small)), ) .selectable(false) .truncate(), @@ -370,7 +406,11 @@ fn render_profile_item( clicked_response = clicked_response.union(resp.response); - clicked_response.clicked() + if clicked_response.clicked() { + *checked = !*checked; + } + + pfp_response } #[derive(Default)] @@ -385,8 +425,10 @@ struct Nip51SetUiState { } impl Nip51SetUiCache { - pub fn get_pk_selected_state(&mut self, identifier: &str, pk: &Pubkey) -> &mut bool { - let pack_state = match self.state.raw_entry_mut().from_key(identifier) { + /// Gets or creates a mutable reference to the UI state for a given pack identifier. If the + /// pack state doesn't exist, it will be initialized with default values. + fn entry_for_pack(&mut self, identifier: &str) -> &mut Nip51SetUiState { + match self.state.raw_entry_mut().from_key(identifier) { RawEntryMut::Occupied(entry) => entry.into_mut(), RawEntryMut::Vacant(entry) => { let (_, pack_state) = @@ -394,7 +436,12 @@ impl Nip51SetUiCache { pack_state } - }; + } + } + + pub fn get_pk_selected_state(&mut self, identifier: &str, pk: &Pubkey) -> &mut bool { + let pack_state = self.entry_for_pack(identifier); + match pack_state.select_pk.raw_entry_mut().from_key(pk) { RawEntryMut::Occupied(entry) => entry.into_mut(), RawEntryMut::Vacant(entry) => { @@ -405,14 +452,17 @@ impl Nip51SetUiCache { } pub fn get_select_all_state(&mut self, identifier: &str) -> &mut bool { - match self.state.raw_entry_mut().from_key(identifier) { - RawEntryMut::Occupied(entry) => &mut entry.into_mut().select_all, - RawEntryMut::Vacant(entry) => { - let (_, pack_state) = - entry.insert(identifier.to_owned(), Nip51SetUiState::default()); + &mut self.entry_for_pack(identifier).select_all + } - &mut pack_state.select_all - } + /// Applies a selection state to all profiles in a pack. Updates both the pack's select_all + /// flag and individual profile selection states. + pub fn apply_select_all_to_pack(&mut self, identifier: &str, pks: &[Pubkey], value: bool) { + let pack_state = self.entry_for_pack(identifier); + pack_state.select_all = value; + + for pk in pks { + pack_state.select_pk.insert(*pk, value); } }