notedeck

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

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:
Mcrates/notedeck_columns/src/nav.rs | 58+++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/notedeck_columns/src/timeline/kind.rs | 29++++++++++++++++-------------
Mcrates/notedeck_columns/src/ui/profile/contacts_list.rs | 115+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
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()), + ); } });