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:
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(), ¬edeck::NotedeckTextStyle::Heading),
- )));
- }
- if let Some(desc) = &pack.description {
- ui.add(egui::Label::new(
- egui::RichText::new(desc)
- .size(get_font_size(
- ui.ctx(),
- ¬edeck::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(), ¬edeck::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(),
+ ¬edeck::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);
}
}