commit d7306272d571add0c5bb7de66043e649fb7b0b73
parent 211ec31d8090d4aa84331bcd5670074d0baa23e7
Author: Martti Malmi <sirius@iki.fi>
Date: Wed, 5 Nov 2025 17:09:44 +0200
follows view
Changelog-Added: Follows view
Diffstat:
4 files changed, 190 insertions(+), 117 deletions(-)
diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs
@@ -946,20 +946,56 @@ 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![]
- }
+ let cache_id = egui::Id::new(("following_contacts_cache", pubkey));
+
+ let contacts = ui
+ .ctx()
+ .data_mut(|d| d.get_temp::<Vec<enostr::Pubkey>>(cache_id));
+
+ let (txn, contacts) = if let Some(cached) = contacts {
+ let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn");
+ (txn, cached)
} else {
- vec![]
+ let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn");
+ let filter = nostrdb::Filter::new()
+ .authors([pubkey.bytes()])
+ .kinds([3])
+ .limit(1)
+ .build();
+
+ let mut contacts = vec![];
+ if let Ok(results) = ctx.ndb.query(&txn, &[filter], 1) {
+ if let Some(result) = results.first() {
+ for tag in result.note.tags() {
+ if tag.count() >= 2 {
+ if let Some("p") = tag.get_str(0) {
+ if let Some(pk_bytes) = tag.get_id(1) {
+ contacts.push(enostr::Pubkey::new(*pk_bytes));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ contacts.sort_by_cached_key(|pk| {
+ ctx.ndb
+ .get_profile_by_pubkey(&txn, pk.bytes())
+ .ok()
+ .and_then(|p| {
+ notedeck::name::get_display_name(Some(&p))
+ .display_name
+ .map(|s| s.to_lowercase())
+ })
+ .unwrap_or_else(|| "zzz".to_string())
+ });
+
+ ui.ctx()
+ .data_mut(|d| d.insert_temp(cache_id, contacts.clone()));
+ (txn, contacts)
};
- crate::ui::profile::ContactsListView::new(pubkey, contacts, &mut note_context)
+ crate::ui::profile::ContactsListView::new(pubkey, contacts, &mut note_context, &txn)
.ui(ui)
.map_output(|action| match action {
crate::ui::profile::ContactsListAction::OpenProfile(pk) => {
diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs
@@ -565,11 +565,14 @@ impl TimelineKind {
}
}
- TimelineKind::Profile(pk) => Some(Timeline::new(
- TimelineKind::profile(pk),
- FilterState::ready_hybrid(profile_filter(pk.bytes())),
- TimelineTab::full_tabs(),
- )),
+ TimelineKind::Profile(pk) => {
+ let filter = profile_filter(pk.bytes());
+ Some(Timeline::new(
+ TimelineKind::profile(pk),
+ FilterState::ready_hybrid(filter),
+ TimelineTab::full_tabs(),
+ ))
+ }
TimelineKind::Notifications(pk) => {
let notifications_filter = notifications_filter(&pk);
@@ -751,14 +754,14 @@ fn profile_filter(pk: &[u8; 32]) -> HybridFilter {
kind: ValidKind::Six,
},
];
- HybridFilter::split(
- local,
- vec![Filter::new()
- .authors([pk])
- .kinds([1, 6, 0])
- .limit(default_remote_limit())
- .build()],
- )
+
+ let remote = vec![Filter::new()
+ .authors([pk])
+ .kinds([1, 6, 0, 3])
+ .limit(default_remote_limit())
+ .build()];
+
+ HybridFilter::split(local, remote)
}
fn search_filter(s: &SearchQuery) -> Vec<Filter> {
diff --git a/crates/notedeck_columns/src/ui/profile/contacts_list.rs b/crates/notedeck_columns/src/ui/profile/contacts_list.rs
@@ -1,4 +1,4 @@
-use egui::{RichText, ScrollArea, Sense};
+use egui::{RichText, Sense};
use enostr::Pubkey;
use nostrdb::Transaction;
use notedeck::{name::get_display_name, profile::get_profile_url, NoteContext};
@@ -6,84 +6,87 @@ use notedeck_ui::ProfilePic;
use crate::nav::BodyResponse;
-pub struct ContactsListView<'a, 'd> {
- pubkey: &'a Pubkey,
+pub struct ContactsListView<'a, 'd, 'txn> {
contacts: Vec<Pubkey>,
note_context: &'a mut NoteContext<'d>,
+ txn: &'txn Transaction,
}
+#[derive(Clone)]
pub enum ContactsListAction {
OpenProfile(Pubkey),
}
-impl<'a, 'd> ContactsListView<'a, 'd> {
+impl<'a, 'd, 'txn> ContactsListView<'a, 'd, 'txn> {
pub fn new(
- pubkey: &'a Pubkey,
+ _pubkey: &'a Pubkey,
contacts: Vec<Pubkey>,
note_context: &'a mut NoteContext<'d>,
+ txn: &'txn Transaction,
) -> Self {
ContactsListView {
- pubkey,
contacts,
note_context,
+ txn,
}
}
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(),
+
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ let clip_rect = ui.clip_rect();
+
+ for contact_pubkey in &self.contacts {
+ let (rect, resp) =
+ ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), Sense::click());
+
+ if !clip_rect.intersects(rect) {
+ continue;
+ }
+
+ let profile = self
+ .note_context
+ .ndb
+ .get_profile_by_pubkey(self.txn, contact_pubkey.bytes())
+ .ok();
+
+ let display_name = get_display_name(profile.as_ref());
+ let name_str = display_name.display_name.unwrap_or("Anonymous");
+ let profile_url = get_profile_url(profile.as_ref());
+
+ let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
+
+ if resp.hovered() {
+ ui.painter()
+ .rect_filled(rect, 0.0, ui.visuals().widgets.hovered.weak_bg_fill);
+ }
+
+ let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect));
+ child_ui.horizontal(|ui| {
+ ui.add_space(16.0);
+
+ ui.add(
+ &mut ProfilePic::new(self.note_context.img_cache, profile_url).size(48.0),
+ );
+
+ ui.add_space(12.0);
+
+ ui.add(
+ egui::Label::new(
+ RichText::new(name_str)
+ .size(16.0)
+ .color(ui.visuals().text_color()),
+ )
+ .selectable(false),
);
+ });
- 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));
- }
+ 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
@@ -95,7 +95,7 @@ impl<'a, 'd> ProfileView<'a, 'd> {
.ok();
if let Some(profile_view_action) =
- profile_body(ui, self.pubkey, self.note_context, profile.as_ref())
+ profile_body(ui, self.pubkey, self.note_context, profile.as_ref(), &txn)
{
action = Some(profile_view_action);
}
@@ -150,6 +150,7 @@ fn profile_body(
pubkey: &Pubkey,
note_context: &mut NoteContext,
profile: Option<&ProfileRecord<'_>>,
+ txn: &Transaction,
) -> Option<ProfileViewAction> {
let mut action = None;
ui.vertical(|ui| {
@@ -263,7 +264,7 @@ fn profile_body(
ui.add_space(8.0);
- if let Some(stats_action) = profile_stats(ui, pubkey, note_context) {
+ if let Some(stats_action) = profile_stats(ui, pubkey, note_context, txn) {
action = Some(stats_action);
}
@@ -309,53 +310,83 @@ fn profile_stats(
ui: &mut egui::Ui,
pubkey: &Pubkey,
note_context: &mut NoteContext,
+ txn: &Transaction,
) -> 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
+ let filter = nostrdb::Filter::new()
+ .authors([pubkey.bytes()])
+ .kinds([3])
+ .limit(1)
+ .build();
+
+ let mut count = 0;
+ let following_count = {
+ if let Ok(results) = note_context.ndb.query(txn, &[filter], 1) {
+ if let Some(result) = results.first() {
+ for tag in result.note.tags() {
+ if tag.count() >= 2 {
+ if let Some("p") = tag.get_str(0) {
+ if tag.get_id(1).is_some() {
+ count += 1;
+ }
+ }
+ }
+ }
+ }
}
- } else {
- None
+
+ count
};
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"
- ))
+ let resp = ui
+ .label(
+ RichText::new(format!("{} ", following_count))
.size(notedeck::fonts::get_font_size(
ui.ctx(),
&NotedeckTextStyle::Small,
))
- .color(ui.visuals().weak_text_color()),
- )
- .on_hover_cursor(egui::CursorIcon::PointingHand);
+ .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));
- }
+ if resp.clicked() || resp2.clicked() {
+ action = Some(ProfileViewAction::ShowFollowing(*pubkey));
+ }
+
+ let selected = note_context.accounts.get_selected_account();
+ if &selected.key.pubkey != pubkey
+ && selected.is_following(pubkey.bytes()) == notedeck::IsFollowing::Yes
+ {
+ ui.add_space(8.0);
+ ui.label(
+ RichText::new(tr!(
+ note_context.i18n,
+ "Follows you",
+ "Badge indicating user follows you"
+ ))
+ .size(notedeck::fonts::get_font_size(
+ ui.ctx(),
+ &NotedeckTextStyle::Tiny,
+ ))
+ .color(ui.visuals().weak_text_color()),
+ );
}
});