notedeck

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

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:
MCargo.lock | 2+-
MCargo.toml | 2+-
Mcrates/enostr/src/relay/pool.rs | 9+++------
Mcrates/notedeck/src/urls.rs | 3++-
Mcrates/notedeck_clndash/src/event.rs | 9+++------
Mcrates/notedeck_clndash/src/summary.rs | 2+-
Mcrates/notedeck_dave/src/tools.rs | 5-----
Dcrates/notedeck_notebook/src/debug.rs | 26--------------------------
Mcrates/notedeck_ui/src/anim.rs | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_ui/src/debug.rs | 24++++++++++++++++++++++++
Mcrates/notedeck_ui/src/lib.rs | 4+++-
Mcrates/notedeck_ui/src/note/mod.rs | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
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 }