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:
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,
+ );
+}