nip 51 set widget

Signed-off-by: kernelkind <kernelkind@gmail.com>
This commit is contained in:
kernelkind
2025-08-07 17:14:06 -04:00
parent 8399c951fa
commit 2a439b1f30
2 changed files with 396 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ pub mod icons;
pub mod images; pub mod images;
pub mod media; pub mod media;
pub mod mention; pub mod mention;
pub mod nip51_set;
pub mod note; pub mod note;
pub mod profile; pub mod profile;
mod username; mod username;

View File

@@ -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
}
}