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:
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(¤t_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()
+}