//! Notif widget: notification counters, desktop notifications and snackbars. //! `notifs_key` tracks what the user is currently looking at (`chat|jid`…). use serde_json::{json, Value}; use super::P; use crate::{app::Ctx, db, rpc}; pub struct Notification<'a> { pub key: String, pub title: String, pub body: String, pub url: String, pub picture: Option, pub time: i64, pub actions: Value, pub data: Value, pub rpc_call: Option<&'a str>, } /// Number of conversations with unread messages pub async fn unreads(ctx: &Ctx) -> i64 { let me = ctx.jid(); let rooms: Vec = ctx .st(|st| st.conferences.values().filter(|c| c.notify > 0).map(|c| c.jid.clone()).collect()) .unwrap_or_default(); db::unreads_total(ctx.db(), &me, &rooms).await.unwrap_or(0) } pub async fn append(ctx: &Ctx, n: Notification<'_>) { let picture = n.picture.clone().unwrap_or_else(|| format!("{}theme/img/app/128.png", ctx.base_uri())); let mut data = n.data.clone(); data["url"] = json!(n.url); crate::push::notify(ctx, &n.key, &n.title, &n.body, &picture, &data).await; rpc!( ctx, "Notif.desktop", n.key.clone(), chrono::Utc::now().timestamp(), n.title.clone(), n.body.clone(), picture.clone(), n.actions.clone(), data ); let first = n.key.split('|').next().unwrap_or("").to_string(); let current = ctx.st(|st| st.notifs_key.clone()).flatten(); let (first_count, key_count) = ctx .st(|st| { let c = st.notifs.shift_remove(&first).unwrap_or(0) + 1; st.notifs.insert(first.clone(), c); let mut k = 0; if first != n.key { k = st.notifs.shift_remove(&n.key).unwrap_or(0) + 1; st.notifs.insert(n.key.clone(), k); } (c, k) }) .unwrap_or((0, 0)); // Do not notify what is currently displayed if current.as_deref() == Some(n.key.as_str()) { return; } if first == "chat" { rpc!(ctx, "Notif.counter", first.clone(), unreads(ctx).await); } else { rpc!(ctx, "Notif.counter", first.clone(), first_count); } if let Some(call) = n.rpc_call { rpc!(ctx, call); } if first != n.key { rpc!(ctx, "Notif.counter", n.key.clone(), key_count); } if !n.title.is_empty() { let html = ctx.render( "Notif/_notif.html", json!({ "title": n.title, "body": n.body, "picture": picture, "url": n.url }), ); rpc!(ctx, "Notif.snackbar", html, n.time); } super::presence::refresh_shortcuts(ctx).await; } pub async fn clear(ctx: &Ctx, key: &str) { let removed = ctx .st(|st| { let counter = st.notifs.shift_remove(key)?; let first = key.split('|').next().unwrap_or("").to_string(); let mut first_left = None; if let Some(v) = st.notifs.get_mut(&first) { *v -= counter; first_left = Some((first.clone(), (*v).max(0))); if *v <= 0 { st.notifs.shift_remove(&first); } } Some(first_left) }) .flatten(); if let Some(first_left) = removed { rpc!(ctx, "Notif.counter", key, 0); rpc!(ctx, "Notif.clear", key); if let Some((first, left)) = first_left { rpc!(ctx, "Notif.counter", first, left); } if let Some(jid) = key.strip_prefix("chat|") { rpc!(ctx, "Shortcuts.clear", jid); } } } pub fn current(ctx: &Ctx) -> Option { ctx.st(|st| st.notifs_key.clone()).flatten() } /// Is the conversation currently displayed and focused? pub fn is_current(ctx: &Ctx, jid: &str) -> bool { current(ctx).is_some_and(|k| k == format!("chat|{jid}")) } pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool { match func { "ajaxClear" => clear(ctx, &p.str(0)).await, "ajaxGet" => { let chat = p.v(0).is_none() || p.b(0); let mut notifs: serde_json::Map = ctx .st(|st| st.notifs.iter().map(|(k, v)| (k.clone(), json!(v))).collect()) .unwrap_or_default(); if chat { notifs.insert("chat".into(), json!(unreads(ctx).await)); } rpc!(ctx, "Notif.refresh", Value::Object(notifs)); } "ajaxCurrent" => { let key = p.str(0); if key.contains('|') { clear(ctx, &key).await; } let was_blurred = ctx.st(|st| st.notifs_key.as_deref() == Some("blurred")).unwrap_or(false); ctx.st(|st| st.notifs_key = Some(key.clone())); if was_blurred { if let Some(jid) = key.strip_prefix("chat|") { super::chat::on_focus_back(ctx, jid).await; } } } "ajaxRegisterPushSubscrition" => { let (endpoint, auth, p256dh) = (p.str(0), p.str(1), p.str(2)); if endpoint.starts_with("https://") { let _ = db::push_subscription_save(ctx.db(), &ctx.jid(), &endpoint, &p256dh, &auth).await; } } "ajaxHttpTouchPushSubscription" => { let _ = db::push_subscription_touch(ctx.db(), &p.str(0)).await; } "ajaxHttpRequest" => { super::dialog(ctx, &ctx.render("Notif/_notif_request.html", json!({})), false, false); } "ajaxRequestGranted" => { rpc!( ctx, "Notif.desktop", chrono::Utc::now().timestamp(), ctx.t("notification.request_title", &[]), ctx.t("notification.request_granted", &[]), Value::Null, Value::Null, Value::Null, Value::Null, Value::Null, true ); } "ajaxRequestDenied" => ctx.toast(&ctx.t("notification.request_denied", &[])), _ => return false, } true } pub async fn on_chat_counter(ctx: &Ctx) { let n = unreads(ctx).await; rpc!(ctx, "Notif.counter", "chat", n); rpc!(ctx, "MovimUtils.setDataItem", "#chatcounter", "counter", n); rpc!(ctx, "MovimUtils.setDataItem", "#chatheadercounter", "counter", n); rpc!(ctx, "BottomNavigation.setChatNotification", json!({ "chat": n })); }