commit 952ba14d46ac8d9ab4e8f5894fca2920b6ef3c83
parent 155278dd3fca730faa2e4b153fe959cf03844fe9
Author: William Casarin <jb55@jb55.com>
Date: Thu, 6 Nov 2025 21:38:00 -0800
Merge Note Metadata + stats #1188
William Casarin (13):
net: switch ping/pong messages to trace
update nostrdb
clippy fixes
add is_root_note helper
ui: note metadata stats
ui: rename actionbar function
ui: move debug slider to ui crate
ui: add rolling number function
ui/note: use rolling numbers for note stats
windows: fix time overflow crash
nostrdb: update for windows fix
clndash: clippy fix
Changelog-Added: Add realtime note stats
Diffstat:
12 files changed, 249 insertions(+), 63 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3520,7 +3520,7 @@ dependencies = [
[[package]]
name = "nostrdb"
version = "0.8.0"
-source = "git+https://github.com/damus-io/nostrdb-rs?rev=2b2e5e43c019b80b98f1db6a03a1b88ca699bfa3#2b2e5e43c019b80b98f1db6a03a1b88ca699bfa3"
+source = "git+https://github.com/damus-io/nostrdb-rs?rev=035bb156dbedd7b058c7ccc176b7141b15436a41#035bb156dbedd7b058c7ccc176b7141b15436a41"
dependencies = [
"bindgen",
"cc",
diff --git a/Cargo.toml b/Cargo.toml
@@ -46,7 +46,7 @@ md5 = "0.7.0"
nostr = { version = "0.37.0", default-features = false, features = ["std", "nip49"] }
nwc = "0.39.0"
mio = { version = "1.0.3", features = ["os-poll", "net"] }
-nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "2b2e5e43c019b80b98f1db6a03a1b88ca699bfa3" }
+nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "035bb156dbedd7b058c7ccc176b7141b15436a41" }
#nostrdb = "0.6.1"
notedeck = { path = "crates/notedeck" }
notedeck_chrome = { path = "crates/notedeck_chrome" }
diff --git a/crates/enostr/src/relay/pool.rs b/crates/enostr/src/relay/pool.rs
@@ -7,11 +7,8 @@ use std::time::{Duration, Instant};
use url::Url;
-#[cfg(not(target_arch = "wasm32"))]
use ewebsock::{WsEvent, WsMessage};
-
-#[cfg(not(target_arch = "wasm32"))]
-use tracing::{debug, error};
+use tracing::{debug, error, trace};
use super::subs_debug::SubsDebug;
@@ -257,7 +254,7 @@ impl RelayPool {
let should_ping = now - relay.last_ping > self.ping_rate;
if should_ping {
- debug!("pinging {}", relay.relay.url);
+ trace!("pinging {}", relay.relay.url);
relay.relay.ping();
relay.last_ping = Instant::now();
}
@@ -382,7 +379,7 @@ impl RelayPool {
// We only need to do this natively.
#[cfg(not(target_arch = "wasm32"))]
if let WsMessage::Ping(ref bs) = ev {
- debug!("pong {}", relay.url());
+ trace!("pong {}", relay.url());
match relay {
PoolRelay::Websocket(wsr) => {
wsr.relay.sender.send(WsMessage::Pong(bs.to_owned()));
diff --git a/crates/notedeck/src/urls.rs b/crates/notedeck/src/urls.rs
@@ -407,7 +407,8 @@ impl UrlMimes {
url.to_owned(),
CachedMime {
mime: None,
- expires_at: SystemTime::UNIX_EPOCH + Duration::from_secs(u64::MAX / 2), // never expire...
+ expires_at: SystemTime::UNIX_EPOCH
+ + Duration::from_secs(253_402_300_799 / 2), // never expire...
},
);
}
diff --git a/crates/notedeck_clndash/src/event.rs b/crates/notedeck_clndash/src/event.rs
@@ -8,18 +8,15 @@ pub enum ConnectionState {
Connecting,
Active,
}
+
+#[derive(Default)]
pub enum LoadingState<T, E> {
+ #[default]
Loading,
Failed(E),
Loaded(T),
}
-impl<T, E> Default for LoadingState<T, E> {
- fn default() -> Self {
- Self::Loading
- }
-}
-
impl<T, E> LoadingState<T, E> {
fn _as_ref(&self) -> LoadingState<&T, &E> {
match self {
diff --git a/crates/notedeck_clndash/src/summary.rs b/crates/notedeck_clndash/src/summary.rs
@@ -102,7 +102,7 @@ pub fn summary_cards_ui(ui: &mut egui::Ui, s: &Summary, prev: Option<&Summary>)
}
// If the last row wasn't full, close it anyway
- if items_len % cols != 0 {
+ if !items_len.is_multiple_of(cols) {
ui.end_row();
}
});
diff --git a/crates/notedeck_dave/src/tools.rs b/crates/notedeck_dave/src/tools.rs
@@ -145,11 +145,6 @@ pub enum ToolCalls {
Invalid(InvalidToolCall),
}
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct ErrorCall {
- error: String,
-}
-
impl ToolCalls {
pub fn to_api(&self) -> FunctionCall {
FunctionCall {
diff --git a/crates/notedeck_notebook/src/debug.rs b/crates/notedeck_notebook/src/debug.rs
@@ -1,26 +0,0 @@
-
-/*
-fn debug_slider(
- ui: &mut egui::Ui,
- id: egui::Id,
- point: Pos2,
- initial: f32,
- range: std::ops::RangeInclusive<f32>,
-) -> f32 {
- let mut val = ui.data_mut(|d| *d.get_temp_mut_or::<f32>(id, initial));
- let nudge = vec2(10.0, 10.0);
- let slider = Rect::from_min_max(point - nudge, point + nudge);
- let label = Rect::from_min_max(point + nudge * 2.0, point - nudge * 2.0);
-
- let old_val = val;
- ui.put(slider, egui::Slider::new(&mut val, range));
- ui.put(label, egui::Label::new(format!("{val}")));
-
- if val != old_val {
- ui.data_mut(|d| d.insert_temp(id, val))
- }
-
- val
-}
-*/
-
diff --git a/crates/notedeck_ui/src/anim.rs b/crates/notedeck_ui/src/anim.rs
@@ -212,3 +212,120 @@ impl<'a> PulseAlpha<'a> {
(cur_val + alpha_min_f32).clamp(self.alpha_min as f32, self.alpha_max as f32) as u8
}
}
+
+/// Stateless rolling number using egui's internal animation memory.
+/// Each digit has a different "speed" / easing.
+pub fn rolling_number(ui: &mut egui::Ui, id_source: impl std::hash::Hash, value: u32) -> Response {
+ let ctx = ui.ctx();
+ let id = ui.make_persistent_id(id_source);
+
+ // Global animated value (one float in egui's memory):
+ let anim = ctx.animate_value_with_time(id, value as f32, 0.35);
+
+ let anim_floor = anim.floor().max(0.0);
+ let base = anim_floor as u32;
+ let t_global = anim - anim_floor; // base step phase: 0..1
+ let next = if t_global == 0.0 {
+ base
+ } else {
+ base.saturating_add(1)
+ };
+
+ // Choose how many digits we want to show.
+ let max_show = value.max(next);
+ let num_digits = max_show.to_string().len().max(1);
+
+ let font_size = 12.0;
+ let font_id = egui::FontId::proportional(font_size);
+ let color = ui.visuals().text_color();
+
+ let response = ui.allocate_response(egui::Vec2::ZERO, Sense::hover());
+
+ let prev_spacing = ui.spacing().item_spacing.x;
+ ui.spacing_mut().item_spacing.x = 0.0;
+ //let pos = ui.available_rect_before_wrap().min;
+ let digit_size = egui::vec2(7.0, font_size);
+
+ for i in 0..num_digits {
+ // Leftmost digit = index 0, rightmost = num_digits - 1
+ let place = 10_u32.pow((num_digits - 1 - i) as u32);
+ let from = (base / place) % 10;
+ let to = (next / place) % 10;
+
+ // Per-digit "speed": rightmost digits move more / earlier.
+ let idx_from_right = (num_digits - 1 - i) as f32;
+ // tweak these constants to taste:
+ let speed_factor = 0.8 + 0.25 * idx_from_right; // higher place → slightly faster
+
+ // Local phase for this digit:
+ let mut t_digit = (t_global * speed_factor).clamp(0.0, 1.0);
+
+ // Add a nice easing curve so some digits ease in/out:
+ t_digit = ease_in_out_cubic(t_digit);
+
+ draw_rolling_digit(
+ ui, from as u8, to as u8, t_digit, &font_id, color, digit_size,
+ );
+ }
+
+ ui.spacing_mut().item_spacing.x = prev_spacing;
+
+ response
+}
+
+// Basic cubic ease-in-out
+fn ease_in_out_cubic(t: f32) -> f32 {
+ if t < 0.5 {
+ 4.0 * t * t * t
+ } else {
+ let t = 2.0 * t - 2.0;
+ 0.5 * t * t * t + 1.0
+ }
+}
+
+fn draw_rolling_digit(
+ ui: &mut egui::Ui,
+ from: u8,
+ to: u8,
+ t: f32, // 0..1, already "warped" per digit
+ font_id: &egui::FontId,
+ color: egui::Color32,
+ desired_size: egui::Vec2,
+) -> egui::Response {
+ let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
+
+ let painter = ui.painter().with_clip_rect(rect);
+
+ let current_str = format!("{from}");
+ let next_str = format!("{to}");
+
+ let current_galley = painter.layout_no_wrap(current_str, font_id.clone(), color);
+ let next_galley = painter.layout_no_wrap(next_str, font_id.clone(), color);
+
+ let h = current_galley.rect.height().max(next_galley.rect.height());
+ let center_x = rect.center().x;
+ let center_y = rect.center().y;
+
+ let current_y = egui::lerp(center_y..=center_y - h, t);
+ let next_y = egui::lerp(center_y + h..=center_y, t);
+
+ painter.galley(
+ egui::pos2(
+ center_x - current_galley.rect.width() * 0.5,
+ current_y - current_galley.rect.height() * 0.5,
+ ),
+ current_galley,
+ egui::Color32::RED,
+ );
+
+ painter.galley(
+ egui::pos2(
+ center_x - next_galley.rect.width() * 0.5,
+ next_y - next_galley.rect.height() * 0.5,
+ ),
+ next_galley,
+ egui::Color32::RED,
+ );
+
+ response
+}
diff --git a/crates/notedeck_ui/src/debug.rs b/crates/notedeck_ui/src/debug.rs
@@ -0,0 +1,24 @@
+use egui::{vec2, Pos2, Rect};
+
+pub fn debug_slider(
+ ui: &mut egui::Ui,
+ id: egui::Id,
+ point: Pos2,
+ initial: f32,
+ range: std::ops::RangeInclusive<f32>,
+) -> f32 {
+ let mut val = ui.data_mut(|d| *d.get_temp_mut_or::<f32>(id, initial));
+ let nudge = vec2(10.0, 10.0);
+ let slider = Rect::from_min_max(point - nudge, point + nudge);
+ let label = Rect::from_min_max(point + nudge * 2.0, point - nudge * 2.0);
+
+ let old_val = val;
+ ui.put(slider, egui::Slider::new(&mut val, range));
+ ui.put(label, egui::Label::new(format!("{val}")));
+
+ if val != old_val {
+ ui.data_mut(|d| d.insert_temp(id, val))
+ }
+
+ val
+}
diff --git a/crates/notedeck_ui/src/lib.rs b/crates/notedeck_ui/src/lib.rs
@@ -3,6 +3,7 @@ pub mod app_images;
pub mod colors;
pub mod constants;
pub mod context_menu;
+pub mod debug;
pub mod icons;
pub mod images;
pub mod media;
@@ -13,7 +14,8 @@ pub mod profile;
mod username;
pub mod widgets;
-pub use anim::{AnimationHelper, PulseAlpha};
+pub use anim::{rolling_number, AnimationHelper, PulseAlpha};
+pub use debug::debug_slider;
pub use icons::{expanding_button, ICON_EXPANSION_MULTIPLE, ICON_WIDTH};
pub use mention::Mention;
pub use note::{NoteContents, NoteOptions, NoteView};
diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs
@@ -452,15 +452,30 @@ impl<'a, 'd> NoteView<'a, 'd> {
// question: WTF? question 2: WHY?
ui.allocate_space(egui::vec2(0.0, 0.0));
- render_note_actionbar(
+ let counts = self
+ .note_context
+ .ndb
+ .get_note_metadata(txn, self.note.id())
+ .ok()
+ .and_then(|md| {
+ md.into_iter().find_map(|e| {
+ if let nostrdb::NoteMetadataEntryVariant::Counts(ce) = e {
+ Some(ce)
+ } else {
+ None
+ }
+ })
+ });
+
+ actionbar_ui(
ui,
+ counts,
get_zapper(
self.note_context.accounts,
self.note_context.global_wallet,
self.note_context.zaps,
),
- self.note.id(),
- self.note.pubkey(),
+ self.note,
self.note_context.accounts.selected_account_pubkey(),
note_key,
self.note_context.i18n,
@@ -539,17 +554,32 @@ impl<'a, 'd> NoteView<'a, 'd> {
note_action = contents.action.or(note_action);
if self.options().contains(NoteOptions::ActionBar) {
+ let counts = self
+ .note_context
+ .ndb
+ .get_note_metadata(txn, self.note.id())
+ .ok()
+ .and_then(|md| {
+ md.into_iter().find_map(|e| {
+ if let nostrdb::NoteMetadataEntryVariant::Counts(ce) = e {
+ Some(ce)
+ } else {
+ None
+ }
+ })
+ });
+
note_action = ui
.horizontal_wrapped(|ui| {
- render_note_actionbar(
+ actionbar_ui(
ui,
+ counts,
get_zapper(
self.note_context.accounts,
self.note_context.global_wallet,
self.note_context.zaps,
),
- self.note.id(),
- self.note.pubkey(),
+ self.note,
self.note_context.accounts.selected_account_pubkey(),
note_key,
self.note_context.i18n,
@@ -844,51 +874,100 @@ fn zap_actionbar_button(
action
}
+fn is_root_note(note: &Note) -> bool {
+ for tag in note.tags() {
+ if tag.count() < 2 {
+ continue;
+ }
+
+ // any reference to an e tag is a non-root note
+ if tag.get_str(0) == Some("e") {
+ return false;
+ }
+ }
+
+ true
+}
+
#[profiling::function]
-fn render_note_actionbar(
+fn actionbar_ui(
ui: &mut egui::Ui,
+ counts: Option<nostrdb::CountsEntry<'_>>,
zapper: Option<Zapper<'_>>,
- note_id: &[u8; 32],
- note_pubkey: &[u8; 32],
+ note: &Note,
current_user_pubkey: &Pubkey,
note_key: NoteKey,
i18n: &mut Localization,
) -> Option<NoteAction> {
let mut action = None;
+ let spacing = 24.0;
+ ui.spacing_mut().item_spacing.x = 2.0;
ui.set_min_height(26.0);
- ui.spacing_mut().item_spacing.x = 24.0;
let reply_resp =
reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
+ if let Some(c) = &counts {
+ let count = if is_root_note(note) {
+ c.thread_replies()
+ } else {
+ c.direct_replies() as u32
+ };
+
+ if count > 0 {
+ //ui.weak(format!("{}", count));
+ crate::anim::rolling_number(ui, egui::Id::new((note_key, "replies")), count);
+ }
+ }
+
+ ui.add_space(spacing);
+
let filled = ui
.ctx()
- .data(|d| d.get_temp(reaction_sent_id(current_user_pubkey, note_id)))
+ .data(|d| d.get_temp(reaction_sent_id(current_user_pubkey, note.id())))
== Some(true);
let like_resp =
like_button(ui, i18n, note_key, filled).on_hover_cursor(egui::CursorIcon::PointingHand);
+ if let Some(c) = &counts {
+ let count = c.reactions();
+ if count > 0 {
+ crate::anim::rolling_number(ui, egui::Id::new((note_key, "likes")), count);
+ }
+ }
+
+ ui.add_space(spacing);
+
let quote_resp =
quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
+ if let Some(c) = &counts {
+ let count = c.quotes() + c.reposts();
+ if count > 0 {
+ crate::anim::rolling_number(ui, egui::Id::new((note_key, "quotes")), count as u32);
+ }
+ }
+
+ ui.add_space(spacing);
+
if reply_resp.clicked() {
- action = Some(NoteAction::Reply(NoteId::new(*note_id)));
+ action = Some(NoteAction::Reply(NoteId::new(*note.id())));
}
if like_resp.clicked() {
action = Some(NoteAction::React(ReactAction::new(
- NoteId::new(*note_id),
+ NoteId::new(*note.id()),
"🤙🏻",
)));
}
if quote_resp.clicked() {
- action = Some(NoteAction::Repost(NoteId::new(*note_id)));
+ action = Some(NoteAction::Repost(NoteId::new(*note.id())));
}
- action = zap_actionbar_button(ui, note_id, note_pubkey, zapper, i18n).or(action);
+ action = zap_actionbar_button(ui, note.id(), note.pubkey(), zapper, i18n).or(action);
action
}