notedeck

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

commit 5a5e04aab32b4096e653cb77d7aeaeb893d43c91
parent 17395b1b09857eed4eff2b790babd491776b3f14
Author: Claude Opus 4.5 <noreply@anthropic.com>
Date:   Fri, 23 Jan 2026 16:28:12 -0800

dashboard: add top posters widget

Track kind1 note authors in rolling buckets and display a "Top Posters"
widget showing the most active posters with profile pictures, names,
and post counts. Uses floor_char_boundary for safe UTF-8 truncation.

Also fixes hyper-rustls build by enabling webpki-roots feature.

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
MCargo.lock | 1+
MCargo.toml | 2+-
Mcrates/notedeck_dashboard/Cargo.toml | 1+
Mcrates/notedeck_dashboard/src/lib.rs | 31+++++++++++++++++++++++++++----
Mcrates/notedeck_dashboard/src/ui.rs | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
5 files changed, 104 insertions(+), 8 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4041,6 +4041,7 @@ dependencies = [ "chrono", "crossbeam-channel", "egui", + "enostr", "nostrdb", "notedeck", "notedeck_ui", diff --git a/Cargo.toml b/Cargo.toml @@ -39,7 +39,7 @@ egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b679 ehttp = "0.5.0" hyper = { version = "1.7.0", features = ["full"] } hyper-util = {version = "0.1" , features = ["tokio"]} -hyper-rustls = "0.27.7" +hyper-rustls = { version = "0.27.7", features = ["webpki-roots"] } http-body-util = "0.1.3" rustls = "0.23.28" enostr = { path = "crates/enostr" } diff --git a/crates/notedeck_dashboard/Cargo.toml b/crates/notedeck_dashboard/Cargo.toml @@ -7,6 +7,7 @@ version.workspace = true rustc-hash = "2.1.1" egui = { workspace = true } +enostr = { workspace = true } nostrdb = { workspace = true } chrono = { workspace = true } crossbeam-channel = { workspace = true } diff --git a/crates/notedeck_dashboard/src/lib.rs b/crates/notedeck_dashboard/src/lib.rs @@ -1,3 +1,4 @@ +use enostr::Pubkey; use nostrdb::Note; use rustc_hash::FxHashMap; use std::thread; @@ -50,6 +51,7 @@ struct Bucket { pub total: u64, pub kinds: rustc_hash::FxHashMap<u64, u32>, pub clients: rustc_hash::FxHashMap<String, u32>, + pub kind1_authors: rustc_hash::FxHashMap<Pubkey, u32>, } fn note_client_tag<'a>(note: &Note<'a>) -> Option<&'a str> { @@ -72,7 +74,15 @@ impl Bucket { #[inline(always)] pub fn bump(&mut self, note: &Note<'_>) { self.total += 1; - *self.kinds.entry(note.kind() as u64).or_default() += 1; + let kind = note.kind(); + *self.kinds.entry(kind as u64).or_default() += 1; + + // Track kind1 authors + if kind == 1 { + let pk = Pubkey::new(*note.pubkey()); + *self.kind1_authors.entry(pk).or_default() += 1; + } + if let Some(client) = note_client_tag(note) { *self.clients.entry(client.to_string()).or_default() += 1; } else { @@ -245,7 +255,7 @@ impl notedeck::App for Dashboard { self.process_worker_msgs(); self.schedule_refresh(); - self.show(ui); + self.show(ui, ctx); AppResponse::none() } @@ -368,8 +378,8 @@ impl Dashboard { } } - fn show(&mut self, ui: &mut egui::Ui) { - crate::ui::dashboard_ui(self, ui); + fn show(&mut self, ui: &mut egui::Ui, ctx: &mut AppContext<'_>) { + crate::ui::dashboard_ui(self, ui, ctx); } } @@ -537,3 +547,16 @@ fn top_kinds_over(cache: &RollingCache, limit: usize) -> Vec<(u64, u64)> { v.truncate(limit); v } + +pub(crate) fn top_kind1_authors_over(cache: &RollingCache, limit: usize) -> Vec<(Pubkey, u64)> { + let mut agg: FxHashMap<Pubkey, u64> = Default::default(); + for b in &cache.buckets { + for (pubkey, count) in &b.kind1_authors { + *agg.entry(*pubkey).or_default() += *count as u64; + } + } + let mut v: Vec<_> = agg.into_iter().collect(); + v.sort_unstable_by(|a, b| b.1.cmp(&a.1)); + v.truncate(limit); + v +} diff --git a/crates/notedeck_dashboard/src/ui.rs b/crates/notedeck_dashboard/src/ui.rs @@ -4,6 +4,10 @@ use egui::RichText; use std::time::Duration; use std::time::Instant; +use nostrdb::Transaction; +use notedeck::{abbrev::floor_char_boundary, name::get_display_name, profile::get_profile_url, AppContext}; +use notedeck_ui::ProfilePic; + use crate::Dashboard; use crate::FxHashMap; use crate::Period; @@ -12,6 +16,7 @@ use crate::chart::Bar; use crate::chart::BarChartStyle; use crate::chart::horizontal_bar_chart; use crate::chart::palette; +use crate::top_kind1_authors_over; use crate::top_kinds_over; pub fn period_picker_ui(ui: &mut egui::Ui, period: &mut Period) { @@ -261,17 +266,17 @@ fn total_over(cache: &RollingCache) -> u64 { cache.buckets.iter().map(|b| b.total).sum() } -pub fn dashboard_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) { +pub fn dashboard_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui, ctx: &mut AppContext<'_>) { egui::Frame::new() .inner_margin(egui::Margin::same(20)) .show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { - dashboard_ui_inner(dashboard, ui); + dashboard_ui_inner(dashboard, ui, ctx); }); }); } -fn dashboard_ui_inner(dashboard: &mut Dashboard, ui: &mut egui::Ui) { +fn dashboard_ui_inner(dashboard: &mut Dashboard, ui: &mut egui::Ui, ctx: &mut AppContext<'_>) { let min_card = 240.0; let gap = 8.0; @@ -297,6 +302,9 @@ fn dashboard_ui_inner(dashboard: &mut Dashboard, ui: &mut egui::Ui) { ui.add_sized(size, |ui: &mut egui::Ui| { card_ui(ui, min_card, |ui| clients_trends_ui(dashboard, ui)) }); + ui.add_sized(size, |ui: &mut egui::Ui| { + card_ui(ui, min_card, |ui| top_posters_ui(dashboard, ui, ctx)) + }); }, ); } @@ -447,3 +455,66 @@ fn top_clients_over(cache: &RollingCache, limit: usize) -> Vec<(String, u64)> { out.truncate(limit); out } + +pub fn top_posters_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui, ctx: &mut AppContext<'_>) { + let cache = dashboard.selected_cache(); + let n = cache.buckets.len(); + let unit = dashboard.period.label(); + let header = format!("Top Posters ({n} {unit}s)"); + card_header_ui(ui, &header); + ui.add_space(8.0); + + let limit = 10; + let top = top_kind1_authors_over(cache, limit); + + if top.is_empty() && dashboard.last_error.is_none() { + ui.label(RichText::new("...").font(FontId::proportional(24.0)).weak()); + return; + } + + let txn = match Transaction::new(ctx.ndb) { + Ok(t) => t, + Err(_) => { + ui.label("DB error"); + return; + } + }; + + let pfp_size = ProfilePic::small_size() as f32; + + for (pubkey, count) in &top { + let profile = ctx.ndb.get_profile_by_pubkey(&txn, pubkey.bytes()).ok(); + let name = get_display_name(profile.as_ref()); + let pfp_url = get_profile_url(profile.as_ref()); + + ui.horizontal(|ui| { + ui.add( + &mut ProfilePic::new(ctx.img_cache, ctx.media_jobs.sender(), pfp_url) + .size(pfp_size), + ); + ui.add_space(6.0); + + let display = name.name(); + let truncated = if display.len() > 16 { + let end = floor_char_boundary(display, 16); + format!("{}...", &display[..end]) + } else { + display.to_string() + }; + ui.label(RichText::new(truncated).small()); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(count.to_string()).small().strong()); + }); + }); + ui.add_space(4.0); + } + + footer_status_ui( + ui, + dashboard.running, + dashboard.last_error.as_deref(), + dashboard.last_snapshot, + dashboard.last_duration, + ); +}