notedeck

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

commit c3499729f2f117e32240e90c492d2d0b3cbdb171
parent dac786e60fe48887c3ca543c36ac245316b42aeb
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 31 Jul 2025 17:14:19 -0700

Merge notification dot by kernel

kernelkind (6):
      extract notifications filter to own method
      add `NotesFreshness` to `TimelineTab`
      set fresh from `TimelineCache`
      chrome: method to find whether there are unseen notifications
      paint unseen indicator
      use unseen notification indicator

Changelog-Added: Add notification dot on toolbar

Diffstat:
Mcrates/notedeck_chrome/src/chrome.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/notedeck_columns/src/nav.rs | 2++
Mcrates/notedeck_columns/src/timeline/cache.rs | 8++++++++
Mcrates/notedeck_columns/src/timeline/kind.rs | 22++++++++++++----------
Mcrates/notedeck_columns/src/timeline/mod.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
5 files changed, 206 insertions(+), 23 deletions(-)

diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -12,7 +12,9 @@ use notedeck::{ UserAccount, WalletType, }; use notedeck_columns::{ - column::SelectionResult, timeline::kind::ListKind, timeline::TimelineKind, Damus, + column::SelectionResult, + timeline::{kind::ListKind, TimelineKind}, + Damus, }; use notedeck_dave::{Dave, DaveAvatar}; use notedeck_notebook::Notebook; @@ -385,7 +387,12 @@ impl Chrome { }); strip.cell(|ui| { - if let Some(action) = self.toolbar(ui) { + let pk = ctx.accounts.get_selected_account().key.pubkey; + + let unseen_notification = + unseen_notification(self.get_columns_app(), ctx.ndb, pk); + + if let Some(action) = self.toolbar(ui, unseen_notification) { got_action = Some(ChromePanelAction::Toolbar(action)) } }); @@ -394,7 +401,7 @@ impl Chrome { got_action } - fn toolbar(&mut self, ui: &mut egui::Ui) -> Option<ToolbarAction> { + fn toolbar(&mut self, ui: &mut egui::Ui, unseen_notification: bool) -> Option<ToolbarAction> { use egui_tabs::{TabColor, Tabs}; let rect = ui.available_rect_before_wrap(); @@ -438,7 +445,9 @@ impl Chrome { action = Some(ToolbarAction::Dave); } } - } else if index == 2 && notifications_button(ui, btn_size).clicked() { + } else if index == 2 + && notifications_button(ui, btn_size, unseen_notification).clicked() + { action = Some(ToolbarAction::Notifications); } @@ -519,6 +528,38 @@ impl Chrome { } } +fn unseen_notification( + columns: Option<&mut Damus>, + ndb: &nostrdb::Ndb, + current_pk: notedeck::enostr::Pubkey, +) -> bool { + let Some(columns) = columns else { + return false; + }; + + let Some(tl) = columns + .timeline_cache + .get_mut(&TimelineKind::Notifications(current_pk)) + else { + return false; + }; + + let freshness = &mut tl.current_view_mut().freshness; + freshness.update(|timestamp_last_viewed| { + let filter = notedeck_columns::timeline::kind::notifications_filter(&current_pk) + .since_mut(timestamp_last_viewed); + let txn = Transaction::new(ndb).expect("txn"); + + let Some(res) = ndb.query(&txn, &[filter], 1).ok() else { + return false; + }; + + !res.is_empty() + }); + + freshness.has_unseen() +} + impl notedeck::App for Chrome { fn update(&mut self, ctx: &mut notedeck::AppContext, ui: &mut egui::Ui) -> Option<AppAction> { if let Some(action) = self.show(ctx, ui) { @@ -572,6 +613,7 @@ fn expanding_button( light_img: egui::Image, dark_img: egui::Image, ui: &mut egui::Ui, + unseen_indicator: bool, ) -> egui::Response { let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget let img = if ui.visuals().dark_mode { @@ -583,16 +625,34 @@ fn expanding_button( let helper = AnimationHelper::new(ui, name, egui::vec2(max_size, max_size)); let cur_img_size = helper.scale_1d_pos(img_size); - img.paint_at( - ui, - helper - .get_animation_rect() - .shrink((max_size - cur_img_size) / 2.0), - ); + + let paint_rect = helper + .get_animation_rect() + .shrink((max_size - cur_img_size) / 2.0); + img.paint_at(ui, paint_rect); + + if unseen_indicator { + paint_unseen_indicator(ui, paint_rect, helper.scale_1d_pos(3.0)); + } helper.take_animation_response() } +fn paint_unseen_indicator(ui: &mut egui::Ui, rect: egui::Rect, radius: f32) { + let center = rect.center(); + let top_right = rect.right_top(); + let distance = center.distance(top_right); + let midpoint = { + let mut cur = center; + cur.x += distance / 2.0; + cur.y -= distance / 2.0; + cur + }; + + let painter = ui.painter_at(rect); + painter.circle_filled(midpoint, radius, notedeck_ui::colors::PINK); +} + fn support_button(ui: &mut egui::Ui) -> egui::Response { expanding_button( "help-button", @@ -600,6 +660,7 @@ fn support_button(ui: &mut egui::Ui) -> egui::Response { app_images::help_light_image(), app_images::help_dark_image(), ui, + false, ) } @@ -610,16 +671,18 @@ fn settings_button(ui: &mut egui::Ui) -> egui::Response { app_images::settings_light_image(), app_images::settings_dark_image(), ui, + false, ) } -fn notifications_button(ui: &mut egui::Ui, size: f32) -> egui::Response { +fn notifications_button(ui: &mut egui::Ui, size: f32, unseen_indicator: bool) -> egui::Response { expanding_button( "notifications-button", size, app_images::notifications_light_image(), app_images::notifications_dark_image(), ui, + unseen_indicator, ) } @@ -630,6 +693,7 @@ fn home_button(ui: &mut egui::Ui, size: f32) -> egui::Response { app_images::home_light_image(), app_images::home_dark_image(), ui, + false, ) } @@ -640,6 +704,7 @@ fn columns_button(ui: &mut egui::Ui) -> egui::Response { app_images::columns_image(), app_images::columns_image(), ui, + false, ) } @@ -650,6 +715,7 @@ fn accounts_button(ui: &mut egui::Ui) -> egui::Response { app_images::accounts_image().tint(ui.visuals().text_color()), app_images::accounts_image(), ui, + false, ) } @@ -660,6 +726,7 @@ fn notebook_button(ui: &mut egui::Ui) -> egui::Response { app_images::algo_image(), app_images::algo_image(), ui, + false, ) } diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -545,6 +545,8 @@ fn render_nav_body( scroll_to_top, ); + app.timeline_cache.set_fresh(kind); + // always clear the scroll_to_top request if scroll_to_top { app.options.remove(AppOptions::ScrollToTop); diff --git a/crates/notedeck_columns/src/timeline/cache.rs b/crates/notedeck_columns/src/timeline/cache.rs @@ -221,6 +221,14 @@ impl TimelineCache { pub fn num_timelines(&self) -> usize { self.timelines.len() } + + pub fn set_fresh(&mut self, kind: &TimelineKind) { + let Some(tl) = self.get_mut(kind) else { + return; + }; + + tl.current_view_mut().freshness.set_fresh(); + } } /// Look for new thread notes since our last fetch diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs @@ -471,11 +471,9 @@ impl TimelineKind { }, // TODO: still need to update this to fetch likes, zaps, etc - TimelineKind::Notifications(pubkey) => FilterState::ready(vec![Filter::new() - .pubkeys([pubkey.bytes()]) - .kinds([1]) - .limit(default_limit()) - .build()]), + TimelineKind::Notifications(pubkey) => { + FilterState::ready(vec![notifications_filter(pubkey)]) + } TimelineKind::Hashtag(hashtag) => { let filters = hashtag @@ -573,11 +571,7 @@ impl TimelineKind { )), TimelineKind::Notifications(pk) => { - let notifications_filter = Filter::new() - .pubkeys([pk.bytes()]) - .kinds([1]) - .limit(default_limit()) - .build(); + let notifications_filter = notifications_filter(&pk); Some(Timeline::new( TimelineKind::notifications(pk), @@ -628,6 +622,14 @@ impl TimelineKind { } } +pub fn notifications_filter(pk: &Pubkey) -> Filter { + Filter::new() + .pubkeys([pk.bytes()]) + .kinds([1]) + .limit(default_limit()) + .build() +} + #[derive(Debug)] pub struct TitleNeedsDb<'a> { kind: &'a TimelineKind, diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs @@ -8,6 +8,7 @@ use crate::{ use notedeck::{ contacts::hybrid_contacts_filter, + debouncer::Debouncer, filter::{self, HybridFilter}, tr, Accounts, CachedNote, ContactState, FilterError, FilterState, FilterStates, Localization, NoteCache, NoteRef, UnknownIds, @@ -16,8 +17,11 @@ use notedeck::{ use egui_virtual_list::VirtualList; use enostr::{PoolRelay, Pubkey, RelayPool}; use nostrdb::{Filter, Ndb, Note, NoteKey, Transaction}; -use std::cell::RefCell; -use std::rc::Rc; +use std::{ + cell::RefCell, + time::{Duration, UNIX_EPOCH}, +}; +use std::{rc::Rc, time::SystemTime}; use tracing::{debug, error, info, warn}; @@ -103,6 +107,7 @@ pub struct TimelineTab { pub selection: i32, pub filter: ViewFilter, pub list: Rc<RefCell<VirtualList>>, + pub freshness: NotesFreshness, } impl TimelineTab { @@ -138,6 +143,7 @@ impl TimelineTab { selection, filter, list, + freshness: NotesFreshness::default(), } } @@ -780,3 +786,101 @@ pub fn is_timeline_ready( } } } + +#[derive(Debug)] +pub struct NotesFreshness { + debouncer: Debouncer, + state: NotesFreshnessState, +} + +#[derive(Debug)] +enum NotesFreshnessState { + Fresh { + timestamp_viewed: u64, + }, + Stale { + have_unseen: bool, + timestamp_last_viewed: u64, + }, +} + +impl Default for NotesFreshness { + fn default() -> Self { + Self { + debouncer: Debouncer::new(Duration::from_secs(2)), + state: NotesFreshnessState::Stale { + have_unseen: true, + timestamp_last_viewed: 0, + }, + } + } +} + +impl NotesFreshness { + pub fn set_fresh(&mut self) { + if !self.debouncer.should_act() { + return; + } + self.state = NotesFreshnessState::Fresh { + timestamp_viewed: timestamp_now(), + }; + self.debouncer.bounce(); + } + + pub fn update(&mut self, check_have_unseen: impl FnOnce(u64) -> bool) { + if !self.debouncer.should_act() { + return; + } + + match &self.state { + NotesFreshnessState::Fresh { timestamp_viewed } => { + let Ok(dur) = SystemTime::now() + .duration_since(UNIX_EPOCH + Duration::from_secs(*timestamp_viewed)) + else { + return; + }; + + if dur > Duration::from_secs(2) { + self.state = NotesFreshnessState::Stale { + have_unseen: check_have_unseen(*timestamp_viewed), + timestamp_last_viewed: *timestamp_viewed, + }; + } + } + NotesFreshnessState::Stale { + have_unseen, + timestamp_last_viewed, + } => { + if *have_unseen { + return; + } + + self.state = NotesFreshnessState::Stale { + have_unseen: check_have_unseen(*timestamp_last_viewed), + timestamp_last_viewed: *timestamp_last_viewed, + }; + } + } + + self.debouncer.bounce(); + } + + pub fn has_unseen(&self) -> bool { + match &self.state { + NotesFreshnessState::Fresh { + timestamp_viewed: _, + } => false, + NotesFreshnessState::Stale { + have_unseen, + timestamp_last_viewed: _, + } => *have_unseen, + } + } +} + +fn timestamp_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs() +}