notedeck

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

commit 211ec31d8090d4aa84331bcd5670074d0baa23e7
parent e32059054091170394a555e1454545fbf98e675b
Author: Martti Malmi <sirius@iki.fi>
Date:   Wed,  5 Nov 2025 16:42:36 +0200

follows list

Changelog-Added: Follows list

Diffstat:
Mcrates/notedeck_columns/src/app.rs | 2++
Mcrates/notedeck_columns/src/nav.rs | 33+++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/route.rs | 34++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/timeline/route.rs | 6++++++
Mcrates/notedeck_columns/src/ui/column/header.rs | 2++
Acrates/notedeck_columns/src/ui/profile/contacts_list.rs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 234 insertions(+), 0 deletions(-)

diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -856,6 +856,8 @@ fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool { Route::Wallet(_) => false, Route::CustomizeZapAmount(_) => false, Route::RepostDecision(_) => false, + Route::Following(_) => false, + Route::FollowedBy(_) => false, } } diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -72,6 +72,8 @@ pub enum RenderNavAction { RelayAction(RelayAction), SettingsAction(SettingsAction), RepostAction(RepostAction), + ShowFollowing(enostr::Pubkey), + ShowFollowers(enostr::Pubkey), } pub enum SwitchingAction { @@ -582,6 +584,14 @@ fn process_render_nav_action( RenderNavAction::RepostAction(action) => { action.process(ctx.ndb, &ctx.accounts.get_selected_account().key, ctx.pool) } + RenderNavAction::ShowFollowing(pubkey) => Some(RouterAction::RouteTo( + crate::route::Route::Following(pubkey), + RouterType::Stack, + )), + RenderNavAction::ShowFollowers(pubkey) => Some(RouterAction::RouteTo( + crate::route::Route::FollowedBy(pubkey), + RouterType::Stack, + )), }; if let Some(action) = router_action { @@ -935,6 +945,29 @@ fn render_nav_body( } }) } + Route::Following(pubkey) => { + let selected = ctx.accounts.get_selected_account(); + let contacts = if &selected.key.pubkey == pubkey { + if let notedeck::ContactState::Received { contacts, .. } = + selected.data.contacts.get_state() + { + contacts.iter().copied().collect() + } else { + vec![] + } + } else { + vec![] + }; + + crate::ui::profile::ContactsListView::new(pubkey, contacts, &mut note_context) + .ui(ui) + .map_output(|action| match action { + crate::ui::profile::ContactsListAction::OpenProfile(pk) => { + RenderNavAction::NoteAction(NoteAction::Profile(pk)) + } + }) + } + Route::FollowedBy(_pubkey) => BodyResponse::none(), Route::Wallet(wallet_type) => { let state = match wallet_type { notedeck::WalletType::Auto => 's: { diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs @@ -31,6 +31,8 @@ pub enum Route { EditDeck(usize), Wallet(WalletType), CustomizeZapAmount(NoteZapTargetOwned), + Following(Pubkey), + FollowedBy(Pubkey), } impl Route { @@ -138,6 +140,14 @@ impl Route { writer.write_token("repost_decision"); writer.write_token(&note_id.hex()); } + Route::Following(pubkey) => { + writer.write_token("following"); + writer.write_token(&pubkey.hex()); + } + Route::FollowedBy(pubkey) => { + writer.write_token("followed_by"); + writer.write_token(&pubkey.hex()); + } } } @@ -259,6 +269,22 @@ impl Route { ))) }) }, + |p| { + p.parse_all(|p| { + p.parse_token("following")?; + let pubkey = Pubkey::from_hex(p.pull_token()?) + .map_err(|_| ParseError::HexDecodeFailed)?; + Ok(Route::Following(pubkey)) + }) + }, + |p| { + p.parse_all(|p| { + p.parse_token("followed_by")?; + let pubkey = Pubkey::from_hex(p.pull_token()?) + .map_err(|_| ParseError::HexDecodeFailed)?; + Ok(Route::FollowedBy(pubkey)) + }) + }, ], ) } @@ -377,6 +403,14 @@ impl Route { "Repost", "Column title for deciding the type of repost" )), + Route::Following(_) => ColumnTitle::formatted(tr!( + i18n, + "Following", + "Column title for users being followed" + )), + Route::FollowedBy(_) => { + ColumnTitle::formatted(tr!(i18n, "Followed by", "Column title for followers")) + } } } } diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs @@ -132,5 +132,11 @@ pub fn render_profile_route( ui::profile::ProfileViewAction::Context(profile_context_selection) => Some( RenderNavAction::ProfileAction(ProfileAction::Context(profile_context_selection)), ), + ui::profile::ProfileViewAction::ShowFollowing(pubkey) => { + Some(RenderNavAction::ShowFollowing(pubkey)) + } + ui::profile::ProfileViewAction::ShowFollowers(pubkey) => { + Some(RenderNavAction::ShowFollowers(pubkey)) + } }) } diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -499,6 +499,8 @@ impl<'a> NavTitle<'a> { Some(self.thread_pfp(ui, thread_selection, pfp_size)) } Route::RepostDecision(_) => None, + Route::Following(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), + Route::FollowedBy(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), } } diff --git a/crates/notedeck_columns/src/ui/profile/contacts_list.rs b/crates/notedeck_columns/src/ui/profile/contacts_list.rs @@ -0,0 +1,90 @@ +use egui::{RichText, ScrollArea, Sense}; +use enostr::Pubkey; +use nostrdb::Transaction; +use notedeck::{name::get_display_name, profile::get_profile_url, NoteContext}; +use notedeck_ui::ProfilePic; + +use crate::nav::BodyResponse; + +pub struct ContactsListView<'a, 'd> { + pubkey: &'a Pubkey, + contacts: Vec<Pubkey>, + note_context: &'a mut NoteContext<'d>, +} + +pub enum ContactsListAction { + OpenProfile(Pubkey), +} + +impl<'a, 'd> ContactsListView<'a, 'd> { + pub fn new( + pubkey: &'a Pubkey, + contacts: Vec<Pubkey>, + note_context: &'a mut NoteContext<'d>, + ) -> Self { + ContactsListView { + pubkey, + contacts, + note_context, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<ContactsListAction> { + let mut action = None; + let scroll_id = egui::Id::new(("contacts_list", self.pubkey)); + + ScrollArea::vertical() + .id_salt(scroll_id) + .animated(false) + .show(ui, |ui| { + ui.add_space(12.0); + + for contact_pubkey in &self.contacts { + let txn = Transaction::new(self.note_context.ndb).expect("txn"); + let profile = self + .note_context + .ndb + .get_profile_by_pubkey(&txn, contact_pubkey.bytes()) + .ok(); + + let (rect, mut resp) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 48.0 + 8.0), + Sense::click(), + ); + + let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + child_ui.horizontal(|ui| { + ui.add_space(12.0); + + ui.add( + &mut ProfilePic::new( + self.note_context.img_cache, + get_profile_url(profile.as_ref()), + ) + .size(48.0), + ); + + ui.add_space(12.0); + + let display_name = get_display_name(profile.as_ref()); + let name_str = display_name.display_name.unwrap_or("Anonymous"); + ui.label( + RichText::new(name_str) + .size(16.0) + .color(ui.visuals().text_color()), + ); + }); + + resp = resp + .interact(Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand); + + if resp.clicked() { + action = Some(ContactsListAction::OpenProfile(*contact_pubkey)); + } + } + }); + + BodyResponse::output(action) + } +} diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -1,5 +1,7 @@ +pub mod contacts_list; pub mod edit; +pub use contacts_list::{ContactsListAction, ContactsListView}; pub use edit::EditProfileView; use egui::{vec2, Color32, CornerRadius, Layout, Rect, RichText, ScrollArea, Sense, Stroke}; use enostr::Pubkey; @@ -39,6 +41,8 @@ pub enum ProfileViewAction { Unfollow(Pubkey), Follow(Pubkey), Context(ProfileContext), + ShowFollowing(Pubkey), + ShowFollowers(Pubkey), } struct ProfileScrollResponse { @@ -257,6 +261,12 @@ fn profile_body( ui.add(about_section_widget(profile)); + ui.add_space(8.0); + + if let Some(stats_action) = profile_stats(ui, pubkey, note_context) { + action = Some(stats_action); + } + ui.horizontal_wrapped(|ui| { let website_url = profile .as_ref() @@ -295,6 +305,63 @@ enum ProfileType { Followable(IsFollowing), } +fn profile_stats( + ui: &mut egui::Ui, + pubkey: &Pubkey, + note_context: &mut NoteContext, +) -> Option<ProfileViewAction> { + let mut action = None; + let selected = note_context.accounts.get_selected_account(); + + let following_count = if &selected.key.pubkey == pubkey { + if let notedeck::ContactState::Received { contacts, .. } = + selected.data.contacts.get_state() + { + Some(contacts.len()) + } else { + None + } + } else { + None + }; + + ui.horizontal(|ui| { + if let Some(count) = following_count { + let resp = ui + .label( + RichText::new(format!("{} ", count)) + .size(notedeck::fonts::get_font_size( + ui.ctx(), + &NotedeckTextStyle::Small, + )) + .color(ui.visuals().text_color()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand); + + let resp2 = ui + .label( + RichText::new(tr!( + note_context.i18n, + "following", + "Label for number of accounts being followed" + )) + .size(notedeck::fonts::get_font_size( + ui.ctx(), + &NotedeckTextStyle::Small, + )) + .color(ui.visuals().weak_text_color()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand); + + if resp.clicked() || resp2.clicked() { + action = Some(ProfileViewAction::ShowFollowing(*pubkey)); + } + } + }); + + action +} + fn handle_link(ui: &mut egui::Ui, website_url: &str) { let img = if ui.visuals().dark_mode { app_images::link_dark_image()