notedeck

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

commit 2a439b1f30ff28af49b86de408066ed2661e2e0a
parent 8399c951faea0937c4317d2f5df1daca60f77827
Author: kernelkind <kernelkind@gmail.com>
Date:   Thu,  7 Aug 2025 17:14:06 -0400

nip 51 set widget

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/notedeck_ui/src/lib.rs | 1+
Acrates/notedeck_ui/src/nip51_set.rs | 395+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 396 insertions(+), 0 deletions(-)

diff --git a/crates/notedeck_ui/src/lib.rs b/crates/notedeck_ui/src/lib.rs @@ -7,6 +7,7 @@ pub mod icons; pub mod images; pub mod media; pub mod mention; +pub mod nip51_set; pub mod note; pub mod profile; mod username; diff --git a/crates/notedeck_ui/src/nip51_set.rs b/crates/notedeck_ui/src/nip51_set.rs @@ -0,0 +1,395 @@ +use bitflags::bitflags; +use egui::{vec2, Checkbox, CornerRadius, Layout, 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, JobPool, JobsCache, + Localization, Nip51Set, Nip51SetCache, NotedeckTextStyle, +}; + +use crate::{ + note::media::{render_media, ScaledTextureFlags}, + ProfilePic, +}; + +pub struct Nip51SetWidget<'a> { + state: &'a Nip51SetCache, + ui_state: &'a mut Nip51SetUiCache, + ndb: &'a Ndb, + images: &'a mut Images, + loc: &'a mut Localization, + job_pool: &'a mut JobPool, + jobs: &'a mut JobsCache, + flags: Nip51SetWidgetFlags, +} + +bitflags! { + #[repr(transparent)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct Nip51SetWidgetFlags: u8 { + const REQUIRES_TITLE = 1u8; + const REQUIRES_IMAGE = 2u8; + const REQUIRES_DESCRIPTION = 3u8; + const NON_EMPTY_PKS = 4u8; + const TRUST_IMAGES = 5u8; + } +} + +impl Default for Nip51SetWidgetFlags { + fn default() -> Self { + Self::empty() + } +} + +pub enum Nip51SetWidgetResponse { + ViewProfile(Pubkey), +} + +impl<'a> Nip51SetWidget<'a> { + pub fn new( + state: &'a Nip51SetCache, + ui_state: &'a mut Nip51SetUiCache, + ndb: &'a Ndb, + loc: &'a mut Localization, + images: &'a mut Images, + job_pool: &'a mut JobPool, + jobs: &'a mut JobsCache, + ) -> Self { + Self { + state, + ui_state, + ndb, + loc, + images, + job_pool, + jobs, + flags: Nip51SetWidgetFlags::default(), + } + } + + pub fn with_flags(mut self, flags: Nip51SetWidgetFlags) -> Self { + self.flags = flags; + self + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<Nip51SetWidgetResponse> { + let mut resp = None; + for pack in self.state.iter() { + if should_skip(pack, &self.flags) { + continue; + } + + egui::Frame::new() + .corner_radius(CornerRadius::same(8)) + .fill(ui.visuals().extreme_bg_color) + .inner_margin(Margin::same(8)) + .show(ui, |ui| { + if let Some(cur_resp) = render_pack( + ui, + pack, + self.ui_state, + self.ndb, + self.images, + self.job_pool, + self.jobs, + self.loc, + self.flags.contains(Nip51SetWidgetFlags::TRUST_IMAGES), + ) { + resp = Some(cur_resp); + } + }); + + ui.add_space(8.0); + } + + resp + } +} + +fn should_skip(set: &Nip51Set, required: &Nip51SetWidgetFlags) -> bool { + (required.contains(Nip51SetWidgetFlags::REQUIRES_TITLE) && set.title.is_none()) + || (required.contains(Nip51SetWidgetFlags::REQUIRES_IMAGE) && set.image.is_none()) + || (required.contains(Nip51SetWidgetFlags::REQUIRES_DESCRIPTION) + && set.description.is_none()) + || (required.contains(Nip51SetWidgetFlags::NON_EMPTY_PKS) && set.pks.is_empty()) +} + +#[allow(clippy::too_many_arguments)] +fn render_pack( + ui: &mut egui::Ui, + pack: &Nip51Set, + ui_state: &mut Nip51SetUiCache, + ndb: &Ndb, + images: &mut Images, + job_pool: &mut JobPool, + jobs: &mut JobsCache, + loc: &mut Localization, + image_trusted: bool, +) -> Option<Nip51SetWidgetResponse> { + let max_img_size = vec2(ui.available_width(), 200.0); + + ui.allocate_new_ui(UiBuilder::new(), |ui| 's: { + let Some(url) = &pack.image else { + break 's; + }; + let Some(media) = images.get_renderable_media(url) else { + break 's; + }; + + let media_rect = render_media( + ui, + images, + job_pool, + jobs, + &media, + image_trusted, + loc, + max_img_size, + None, + ScaledTextureFlags::RESPECT_MAX_DIMS, + ) + .response + .rect; + + 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), + ))); + } + 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; + + let new_select_all_state = if select_all_resp.clicked() { + Some(*ui_state.get_select_all_state(&pack.identifier)) + } else { + None + }; + + let mut resp = None; + for pk in &pack.pks { + let txn = Transaction::new(ndb).expect("txn"); + let m_profile = ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok(); + + 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; + }; + + ui.separator(); + if render_profile_item(ui, images, m_profile.as_ref(), cur_state) { + resp = Some(Nip51SetWidgetResponse::ViewProfile(*pk)); + } + } + + resp +} + +fn render_profile_item( + ui: &mut egui::Ui, + images: &mut Images, + profile: Option<&ProfileRecord>, + checked: &mut bool, +) -> bool { + let (card_rect, card_resp) = + ui.allocate_exact_size(vec2(ui.available_width(), 48.0), egui::Sense::click()); + + let mut clicked_response = card_resp; + + let checkbox_size = { + let mut size = egui::Vec2::splat(ui.spacing().interact_size.y); + size.y = size.y.max(ui.spacing().icon_width); + size + }; + + let (checkbox_section_rect, remaining_rect) = + card_rect.split_left_right_at_x(card_rect.left() + checkbox_size.x + 8.0); + + let checkbox_rect = egui::Rect::from_center_size(checkbox_section_rect.center(), checkbox_size); + + let resp = ui.allocate_new_ui(UiBuilder::new().max_rect(checkbox_rect), |ui| { + ui.add(Checkbox::without_text(checked)); + }); + ui.advance_cursor_after_rect(checkbox_rect); + + clicked_response = clicked_response.union(resp.response); + + let (pfp_rect, body_rect) = remaining_rect.split_left_right_at_x(remaining_rect.left() + 48.0); + + let _ = ui.allocate_new_ui(UiBuilder::new().max_rect(pfp_rect), |ui| { + let pfp_resp = ui.add( + &mut ProfilePic::new(images, get_profile_url(profile)) + .sense(Sense::click()) + .size(48.0), + ); + + 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); + + let (name_rect, description_rect) = body_rect.split_top_bottom_at_fraction(0.5); + + let resp = ui.allocate_new_ui(UiBuilder::new().max_rect(name_rect), |ui| { + let name = get_display_name(profile); + + let painter = ui.painter_at(name_rect); + + let mut left_x_pos = name_rect.left(); + + if let Some(disp) = name.display_name { + let galley = painter.layout_no_wrap( + disp.to_owned(), + NotedeckTextStyle::Heading3.get_font_id(ui.ctx()), + ui.visuals().text_color(), + ); + + left_x_pos += galley.rect.width() + 4.0; + + painter.galley(name_rect.min, galley, ui.visuals().text_color()); + } + + if let Some(username) = name.username { + let galley = painter.layout_no_wrap( + format!("@{username}"), + NotedeckTextStyle::Body.get_font_id(ui.ctx()), + crate::colors::MID_GRAY, + ); + + let pos = { + let mut pos = name_rect.min; + pos.x = left_x_pos; + + let padding = name_rect.height() - galley.rect.height(); + + pos.y += padding / 2.0; + + pos + }; + painter.galley(pos, galley, ui.visuals().text_color()); + } + }); + 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: { + let Some(record) = profile else { + break 's; + }; + + let Some(ndb_profile) = record.record().profile() else { + break 's; + }; + + let Some(about) = ndb_profile.about() else { + break 's; + }; + + ui.add( + egui::Label::new( + RichText::new(about).size(get_font_size(ui.ctx(), &NotedeckTextStyle::Heading4)), + ) + .selectable(false) + .truncate(), + ); + }); + + ui.advance_cursor_after_rect(description_rect); + + clicked_response = clicked_response.union(resp.response); + + clicked_response.clicked() +} + +#[derive(Default)] +pub struct Nip51SetUiCache { + state: HashMap<String, Nip51SetUiState>, +} + +#[derive(Default)] +struct Nip51SetUiState { + select_all: bool, + select_pk: HashMap<Pubkey, bool>, +} + +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) { + RawEntryMut::Occupied(entry) => entry.into_mut(), + RawEntryMut::Vacant(entry) => { + let (_, pack_state) = + entry.insert(identifier.to_owned(), Nip51SetUiState::default()); + + pack_state + } + }; + match pack_state.select_pk.raw_entry_mut().from_key(pk) { + RawEntryMut::Occupied(entry) => entry.into_mut(), + RawEntryMut::Vacant(entry) => { + let (_, state) = entry.insert(*pk, false); + state + } + } + } + + 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 pack_state.select_all + } + } + } + + pub fn get_all_selected(&self) -> Vec<Pubkey> { + let mut pks = Vec::new(); + + for pack in self.state.values() { + for (pk, select_state) in &pack.select_pk { + if !*select_state { + continue; + } + + pks.push(*pk); + } + } + + pks + } +}