[retention] Add setting for auto removing notifications
Some checks failed
CI / Rustfmt (push) Successful in 35s
CI / Flatpak (push) Failing after 6s

This commit is contained in:
2026-03-19 20:52:05 -04:00
parent 750cba8351
commit c112c8dd47
7 changed files with 116 additions and 10 deletions

View File

@ -37,6 +37,10 @@ template $SubscriptionInfoDialog : Adw.Dialog {
Adw.SwitchRow muted_switch_row { Adw.SwitchRow muted_switch_row {
title: "Muted"; title: "Muted";
} }
Adw.SpinRow retention_hours_spin_row {
title: "Retention Hours";
subtitle: "How long messages are stored (0 = forever)";
}
styles [ styles [
"boxed-list" "boxed-list"

View File

@ -0,0 +1 @@
ALTER TABLE subscription ADD COLUMN retention_hours INTEGER DEFAULT 0;

View File

@ -31,6 +31,7 @@ impl Db {
conn.execute_batch(include_str!("./migrations/00.sql"))?; conn.execute_batch(include_str!("./migrations/00.sql"))?;
conn.execute_batch(include_str!("./migrations/01.sql"))?; conn.execute_batch(include_str!("./migrations/01.sql"))?;
conn.execute_batch(include_str!("./migrations/02.sql"))?; conn.execute_batch(include_str!("./migrations/02.sql"))?;
conn.execute_batch(include_str!("./migrations/03.sql"))?;
Ok(()) Ok(())
} }
fn get_or_insert_server(&mut self, server: &str) -> Result<i64> { fn get_or_insert_server(&mut self, server: &str) -> Result<i64> {
@ -56,9 +57,14 @@ impl Db {
tx.commit()?; tx.commit()?;
res 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 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)", "INSERT INTO message (server, data) VALUES (?1, ?2)",
params![server_id, json_data], params![server_id, json_data],
); );
@ -69,9 +75,48 @@ impl Db {
Err(Error::DuplicateMessage) Err(Error::DuplicateMessage)
} }
Err(e) => Err(Error::Db(e)), 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<i64> {
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<u32> = 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( pub fn list_messages(
&self, &self,
server: &str, server: &str,
@ -117,14 +162,15 @@ impl Db {
pub fn insert_subscription(&mut self, sub: models::Subscription) -> Result<(), Error> { pub fn insert_subscription(&mut self, sub: models::Subscription) -> Result<(), Error> {
let server_id = self.get_or_insert_server(&sub.server)?; let server_id = self.get_or_insert_server(&sub.server)?;
self.conn.read().unwrap().execute( 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![ params![
server_id, server_id,
sub.topic, sub.topic,
sub.display_name, sub.display_name,
sub.reserved, sub.reserved,
sub.muted, sub.muted,
sub.archived sub.archived,
sub.retention_hours
], ],
)?; )?;
Ok(()) Ok(())
@ -144,7 +190,7 @@ impl Db {
pub fn list_subscriptions(&mut self) -> Result<Vec<models::Subscription>, Error> { pub fn list_subscriptions(&mut self) -> Result<Vec<models::Subscription>, Error> {
let conn = self.conn.read().unwrap(); let conn = self.conn.read().unwrap();
let mut stmt = conn.prepare( 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 FROM subscription sub
JOIN server ON server.id = sub.server JOIN server ON server.id = sub.server
ORDER BY server.endpoint, sub.display_name, sub.topic ORDER BY server.endpoint, sub.display_name, sub.topic
@ -160,6 +206,7 @@ impl Db {
archived: row.get(5)?, archived: row.get(5)?,
symbolic_icon: row.get(6)?, symbolic_icon: row.get(6)?,
read_until: row.get(7)?, read_until: row.get(7)?,
retention_hours: row.get(8)?,
}) })
})?; })?;
let subs: Result<Vec<_>, rusqlite::Error> = rows.collect(); let subs: Result<Vec<_>, rusqlite::Error> = rows.collect();
@ -170,14 +217,15 @@ impl Db {
let server_id = self.get_or_insert_server(&sub.server)?; let server_id = self.get_or_insert_server(&sub.server)?;
let res = self.conn.read().unwrap().execute( let res = self.conn.read().unwrap().execute(
"UPDATE subscription "UPDATE subscription
SET display_name = ?1, reserved = ?2, muted = ?3, archived = ?4, read_until = ?5 SET display_name = ?1, reserved = ?2, muted = ?3, archived = ?4, read_until = ?5, retention_hours = ?6
WHERE server = ?6 AND topic = ?7", WHERE server = ?7 AND topic = ?8",
params![ params![
sub.display_name, sub.display_name,
sub.reserved, sub.reserved,
sub.muted, sub.muted,
sub.archived, sub.archived,
sub.read_until, sub.read_until,
sub.retention_hours,
server_id, server_id,
sub.topic, sub.topic,
], ],

View File

@ -177,6 +177,7 @@ pub struct Subscription {
pub reserved: bool, pub reserved: bool,
pub symbolic_icon: Option<String>, pub symbolic_icon: Option<String>,
pub read_until: u64, pub read_until: u64,
pub retention_hours: u32,
} }
impl Subscription { impl Subscription {
@ -225,6 +226,7 @@ pub struct SubscriptionBuilder {
reserved: bool, reserved: bool,
symbolic_icon: Option<String>, symbolic_icon: Option<String>,
display_name: String, display_name: String,
retention_hours: u32,
} }
impl SubscriptionBuilder { impl SubscriptionBuilder {
@ -237,6 +239,7 @@ impl SubscriptionBuilder {
reserved: false, reserved: false,
symbolic_icon: None, symbolic_icon: None,
display_name: String::new(), display_name: String::new(),
retention_hours: 0,
} }
} }
@ -270,6 +273,11 @@ impl SubscriptionBuilder {
self self
} }
pub fn retention_hours(mut self, retention_hours: u32) -> Self {
self.retention_hours = retention_hours;
self
}
pub fn build(self) -> Result<Subscription, Error> { pub fn build(self) -> Result<Subscription, Error> {
let res = Subscription { let res = Subscription {
server: self.server, server: self.server,
@ -280,6 +288,7 @@ impl SubscriptionBuilder {
symbolic_icon: self.symbolic_icon, symbolic_icon: self.symbolic_icon,
display_name: self.display_name, display_name: self.display_name,
read_until: 0, read_until: 0,
retention_hours: self.retention_hours,
}; };
res.validate() res.validate()
} }

View File

@ -232,7 +232,11 @@ impl SubscriptionActor {
// Store in database // Store in database
let already_stored: bool = { let already_stored: bool = {
let json_ev = &serde_json::to_string(&msg).unwrap(); 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) => { Err(Error::DuplicateMessage) => {
warn!(topic=?self.model.topic, "received duplicate message"); warn!(topic=?self.model.topic, "received duplicate message");
true true

View File

@ -54,6 +54,8 @@ mod imp {
pub muted: Cell<bool>, pub muted: Cell<bool>,
#[property(get)] #[property(get)]
pub unread_count: Cell<u32>, pub unread_count: Cell<u32>,
#[property(get)]
pub retention_hours: Cell<u32>,
pub read_until: Cell<u64>, pub read_until: Cell<u64>,
pub messages: gio::ListStore, pub messages: gio::ListStore,
pub client: OnceCell<ntfy_daemon::SubscriptionHandle>, pub client: OnceCell<ntfy_daemon::SubscriptionHandle>,
@ -79,6 +81,7 @@ mod imp {
client: Default::default(), client: Default::default(),
unread_count: Default::default(), unread_count: Default::default(),
read_until: Default::default(), read_until: Default::default(),
retention_hours: Default::default(),
} }
} }
} }
@ -124,6 +127,7 @@ impl Subscription {
muted: bool, muted: bool,
read_until: u64, read_until: u64,
display_name: &str, display_name: &str,
retention_hours: u32,
) { ) {
let imp = self.imp(); let imp = self.imp();
imp.topic.replace(topic.to_string()); imp.topic.replace(topic.to_string());
@ -134,6 +138,8 @@ impl Subscription {
self.notify_muted(); self.notify_muted();
imp.read_until.replace(read_until); imp.read_until.replace(read_until);
self.notify_unread_count(); self.notify_unread_count();
imp.retention_hours.replace(retention_hours);
self.notify_retention_hours();
self._set_display_name(display_name.to_string()); self._set_display_name(display_name.to_string());
} }
@ -149,6 +155,7 @@ impl Subscription {
model.muted, model.muted,
model.read_until, model.read_until,
&model.display_name, &model.display_name,
model.retention_hours,
); );
let (prev_msgs, mut rx) = remote_subscription.attach().await; let (prev_msgs, mut rx) = remote_subscription.attach().await;
@ -214,8 +221,9 @@ impl Subscription {
.unwrap() .unwrap()
.update_info( .update_info(
models::Subscription::builder(self.topic()) 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()) .muted(imp.muted.get())
.retention_hours(imp.retention_hours.get())
.build() .build()
.map_err(|e| anyhow::anyhow!("invalid subscription data {:?}", e))?, .map_err(|e| anyhow::anyhow!("invalid subscription data {:?}", e))?,
) )
@ -249,6 +257,15 @@ impl Subscription {
Ok(()) Ok(())
} }
} }
pub fn set_retention_hours(&self, value: u32) -> impl Future<Output = anyhow::Result<()>> {
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<()> { pub async fn flag_all_as_read(&self) -> anyhow::Result<()> {
let imp = self.imp(); let imp = self.imp();
let Some(value) = Self::last_message(&imp.messages) let Some(value) = Self::last_message(&imp.messages)

View File

@ -20,6 +20,8 @@ mod imp {
pub display_name_entry: TemplateChild<adw::EntryRow>, pub display_name_entry: TemplateChild<adw::EntryRow>,
#[template_child] #[template_child]
pub muted_switch_row: TemplateChild<adw::SwitchRow>, pub muted_switch_row: TemplateChild<adw::SwitchRow>,
#[template_child]
pub retention_hours_spin_row: TemplateChild<adw::SpinRow>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -47,6 +49,12 @@ mod imp {
.set_text(&this.subscription().unwrap().display_name()); .set_text(&this.subscription().unwrap().display_name());
self.muted_switch_row self.muted_switch_row
.set_active(this.subscription().unwrap().muted()); .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(); let debouncer = crate::async_utils::Debouncer::new();
self.display_name_entry.connect_changed({ self.display_name_entry.connect_changed({
@ -64,6 +72,14 @@ mod imp {
this.update_muted(switch); 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 {} impl WidgetImpl for SubscriptionInfoDialog {}
@ -99,4 +115,11 @@ impl SubscriptionInfoDialog {
.spawn(async move { sub.set_muted(switch.is_active()).await }) .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 })
}
}
} }