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:
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(¬e_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()