From c112c8dd47d71350a26c7a7c4e397c1588736be1 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Thu, 19 Mar 2026 20:52:05 -0400 Subject: [PATCH] [retention] Add setting for auto removing notifications --- .../resources/ui/subscription_info_dialog.blp | 4 ++ .../src/message_repo/migrations/03.sql | 1 + ntfy-daemon/src/message_repo/mod.rs | 64 ++++++++++++++++--- ntfy-daemon/src/models.rs | 9 +++ ntfy-daemon/src/subscription.rs | 6 +- src/subscription.rs | 19 +++++- src/widgets/subscription_info_dialog.rs | 23 +++++++ 7 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 ntfy-daemon/src/message_repo/migrations/03.sql diff --git a/data/resources/ui/subscription_info_dialog.blp b/data/resources/ui/subscription_info_dialog.blp index f45e06f..041749c 100644 --- a/data/resources/ui/subscription_info_dialog.blp +++ b/data/resources/ui/subscription_info_dialog.blp @@ -37,6 +37,10 @@ template $SubscriptionInfoDialog : Adw.Dialog { Adw.SwitchRow muted_switch_row { title: "Muted"; } + Adw.SpinRow retention_hours_spin_row { + title: "Retention Hours"; + subtitle: "How long messages are stored (0 = forever)"; + } styles [ "boxed-list" diff --git a/ntfy-daemon/src/message_repo/migrations/03.sql b/ntfy-daemon/src/message_repo/migrations/03.sql new file mode 100644 index 0000000..5c2ea84 --- /dev/null +++ b/ntfy-daemon/src/message_repo/migrations/03.sql @@ -0,0 +1 @@ +ALTER TABLE subscription ADD COLUMN retention_hours INTEGER DEFAULT 0; diff --git a/ntfy-daemon/src/message_repo/mod.rs b/ntfy-daemon/src/message_repo/mod.rs index 169ba08..4d1369b 100644 --- a/ntfy-daemon/src/message_repo/mod.rs +++ b/ntfy-daemon/src/message_repo/mod.rs @@ -31,6 +31,7 @@ impl Db { conn.execute_batch(include_str!("./migrations/00.sql"))?; conn.execute_batch(include_str!("./migrations/01.sql"))?; conn.execute_batch(include_str!("./migrations/02.sql"))?; + conn.execute_batch(include_str!("./migrations/03.sql"))?; Ok(()) } fn get_or_insert_server(&mut self, server: &str) -> Result { @@ -56,9 +57,14 @@ impl Db { tx.commit()?; res } - pub fn insert_message(&mut self, server: &str, json_data: &str) -> Result<(), Error> { + pub fn insert_message( + &mut self, + server: &str, + topic: &str, + json_data: &str, + ) -> Result<(), Error> { let server_id = self.get_or_insert_server(server)?; - let res = self.conn.read().unwrap().execute( + let res = self.conn.write().unwrap().execute( "INSERT INTO message (server, data) VALUES (?1, ?2)", params![server_id, json_data], ); @@ -69,9 +75,48 @@ impl Db { Err(Error::DuplicateMessage) } Err(e) => Err(Error::Db(e)), - Ok(_) => Ok(()), + Ok(_) => { + self.cleanup_messages(server, topic)?; + Ok(()) + } } } + + fn server_id_from_endpoint(conn: &Connection, endpoint: &str) -> Result { + conn.query_row( + "SELECT id FROM server WHERE endpoint = ?1", + params![endpoint], + |row| row.get(0), + ) + } + + pub fn cleanup_messages(&self, server: &str, topic: &str) -> Result<(), Error> { + let cutoff = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let conn = self.conn.read().unwrap(); + let mut stmt = conn.prepare( + "SELECT retention_hours FROM subscription sub + JOIN server s ON sub.server = s.id + WHERE s.endpoint = ?1 AND sub.topic = ?2 AND sub.retention_hours > 0", + )?; + + let retention_hours: Option = stmt + .query_row(params![server, topic], |row| row.get(0)) + .ok(); + + if let Some(hours) = retention_hours { + let cutoff = cutoff.saturating_sub((hours as u64) * 3600); + conn.execute( + "DELETE FROM message WHERE server = ?1 AND topic = ?2 AND timestamp < ?3", + params![Self::server_id_from_endpoint(&conn, server)?, topic, cutoff], + )?; + } + Ok(()) + } + pub fn list_messages( &self, server: &str, @@ -117,14 +162,15 @@ impl Db { pub fn insert_subscription(&mut self, sub: models::Subscription) -> Result<(), Error> { let server_id = self.get_or_insert_server(&sub.server)?; self.conn.read().unwrap().execute( - "INSERT INTO subscription (server, topic, display_name, reserved, muted, archived) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO subscription (server, topic, display_name, reserved, muted, archived, retention_hours) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ server_id, sub.topic, sub.display_name, sub.reserved, sub.muted, - sub.archived + sub.archived, + sub.retention_hours ], )?; Ok(()) @@ -144,7 +190,7 @@ impl Db { pub fn list_subscriptions(&mut self) -> Result, Error> { let conn = self.conn.read().unwrap(); let mut stmt = conn.prepare( - "SELECT server.endpoint, sub.topic, sub.display_name, sub.reserved, sub.muted, sub.archived, sub.symbolic_icon, sub.read_until + "SELECT server.endpoint, sub.topic, sub.display_name, sub.reserved, sub.muted, sub.archived, sub.symbolic_icon, sub.read_until, sub.retention_hours FROM subscription sub JOIN server ON server.id = sub.server ORDER BY server.endpoint, sub.display_name, sub.topic @@ -160,6 +206,7 @@ impl Db { archived: row.get(5)?, symbolic_icon: row.get(6)?, read_until: row.get(7)?, + retention_hours: row.get(8)?, }) })?; let subs: Result, rusqlite::Error> = rows.collect(); @@ -170,14 +217,15 @@ impl Db { let server_id = self.get_or_insert_server(&sub.server)?; let res = self.conn.read().unwrap().execute( "UPDATE subscription - SET display_name = ?1, reserved = ?2, muted = ?3, archived = ?4, read_until = ?5 - WHERE server = ?6 AND topic = ?7", + SET display_name = ?1, reserved = ?2, muted = ?3, archived = ?4, read_until = ?5, retention_hours = ?6 + WHERE server = ?7 AND topic = ?8", params![ sub.display_name, sub.reserved, sub.muted, sub.archived, sub.read_until, + sub.retention_hours, server_id, sub.topic, ], diff --git a/ntfy-daemon/src/models.rs b/ntfy-daemon/src/models.rs index 1b01765..a41edeb 100644 --- a/ntfy-daemon/src/models.rs +++ b/ntfy-daemon/src/models.rs @@ -177,6 +177,7 @@ pub struct Subscription { pub reserved: bool, pub symbolic_icon: Option, pub read_until: u64, + pub retention_hours: u32, } impl Subscription { @@ -225,6 +226,7 @@ pub struct SubscriptionBuilder { reserved: bool, symbolic_icon: Option, display_name: String, + retention_hours: u32, } impl SubscriptionBuilder { @@ -237,6 +239,7 @@ impl SubscriptionBuilder { reserved: false, symbolic_icon: None, display_name: String::new(), + retention_hours: 0, } } @@ -270,6 +273,11 @@ impl SubscriptionBuilder { self } + pub fn retention_hours(mut self, retention_hours: u32) -> Self { + self.retention_hours = retention_hours; + self + } + pub fn build(self) -> Result { let res = Subscription { server: self.server, @@ -280,6 +288,7 @@ impl SubscriptionBuilder { symbolic_icon: self.symbolic_icon, display_name: self.display_name, read_until: 0, + retention_hours: self.retention_hours, }; res.validate() } diff --git a/ntfy-daemon/src/subscription.rs b/ntfy-daemon/src/subscription.rs index 7d45ab9..98f6129 100644 --- a/ntfy-daemon/src/subscription.rs +++ b/ntfy-daemon/src/subscription.rs @@ -232,7 +232,11 @@ impl SubscriptionActor { // Store in database let already_stored: bool = { let json_ev = &serde_json::to_string(&msg).unwrap(); - match self.env.db.insert_message(&self.model.server, json_ev) { + match self + .env + .db + .insert_message(&self.model.server, &self.model.topic, json_ev) + { Err(Error::DuplicateMessage) => { warn!(topic=?self.model.topic, "received duplicate message"); true diff --git a/src/subscription.rs b/src/subscription.rs index dcb68c4..e2580c0 100644 --- a/src/subscription.rs +++ b/src/subscription.rs @@ -54,6 +54,8 @@ mod imp { pub muted: Cell, #[property(get)] pub unread_count: Cell, + #[property(get)] + pub retention_hours: Cell, pub read_until: Cell, pub messages: gio::ListStore, pub client: OnceCell, @@ -79,6 +81,7 @@ mod imp { client: Default::default(), unread_count: Default::default(), read_until: Default::default(), + retention_hours: Default::default(), } } } @@ -124,6 +127,7 @@ impl Subscription { muted: bool, read_until: u64, display_name: &str, + retention_hours: u32, ) { let imp = self.imp(); imp.topic.replace(topic.to_string()); @@ -134,6 +138,8 @@ impl Subscription { self.notify_muted(); imp.read_until.replace(read_until); self.notify_unread_count(); + imp.retention_hours.replace(retention_hours); + self.notify_retention_hours(); self._set_display_name(display_name.to_string()); } @@ -149,6 +155,7 @@ impl Subscription { model.muted, model.read_until, &model.display_name, + model.retention_hours, ); let (prev_msgs, mut rx) = remote_subscription.attach().await; @@ -214,8 +221,9 @@ impl Subscription { .unwrap() .update_info( models::Subscription::builder(self.topic()) - .display_name((imp.display_name.borrow().to_string())) + .display_name(imp.display_name.borrow().to_string()) .muted(imp.muted.get()) + .retention_hours(imp.retention_hours.get()) .build() .map_err(|e| anyhow::anyhow!("invalid subscription data {:?}", e))?, ) @@ -249,6 +257,15 @@ impl Subscription { Ok(()) } } + pub fn set_retention_hours(&self, value: u32) -> impl Future> { + let this = self.clone(); + async move { + this.imp().retention_hours.replace(value); + this.notify_retention_hours(); + this.send_updated_info().await?; + Ok(()) + } + } pub async fn flag_all_as_read(&self) -> anyhow::Result<()> { let imp = self.imp(); let Some(value) = Self::last_message(&imp.messages) diff --git a/src/widgets/subscription_info_dialog.rs b/src/widgets/subscription_info_dialog.rs index ca0de5e..0e7be3a 100644 --- a/src/widgets/subscription_info_dialog.rs +++ b/src/widgets/subscription_info_dialog.rs @@ -20,6 +20,8 @@ mod imp { pub display_name_entry: TemplateChild, #[template_child] pub muted_switch_row: TemplateChild, + #[template_child] + pub retention_hours_spin_row: TemplateChild, } #[glib::object_subclass] @@ -47,6 +49,12 @@ mod imp { .set_text(&this.subscription().unwrap().display_name()); self.muted_switch_row .set_active(this.subscription().unwrap().muted()); + self.retention_hours_spin_row + .set_value(this.subscription().unwrap().retention_hours() as f64); + let adj = self.retention_hours_spin_row.adjustment(); + adj.set_upper(8760.0); + adj.set_step_increment(1.0); + adj.set_page_increment(24.0); let debouncer = crate::async_utils::Debouncer::new(); self.display_name_entry.connect_changed({ @@ -64,6 +72,14 @@ mod imp { this.update_muted(switch); } }); + let this = self.obj().clone(); + self.retention_hours_spin_row + .adjustment() + .connect_value_changed({ + move |adj| { + this.update_retention_hours(adj.value() as u32); + } + }); } } impl WidgetImpl for SubscriptionInfoDialog {} @@ -99,4 +115,11 @@ impl SubscriptionInfoDialog { .spawn(async move { sub.set_muted(switch.is_active()).await }) } } + fn update_retention_hours(&self, value: u32) { + if let Some(sub) = self.subscription() { + let sub = sub.clone(); + self.error_boundary() + .spawn(async move { sub.set_retention_hours(value).await }) + } + } }