//! Presence widget (own status, menu, logout) and the session start sequence. //! Also BottomNavigation and Shortcuts that display the same information. use serde_json::json; use super::{contact_view, notif, P}; use crate::{app::Ctx, db, proto, rpc, xmpp}; fn own_presence_key(ctx: &Ctx) -> String { ctx.st(|st| match st.own_show.as_deref() { Some("away") => "away", Some("dnd") => "dnd", Some("xa") => "xa", _ => "online", }) .unwrap_or("online") .to_string() } pub fn build(ctx: &Ctx) -> String { let picture = ctx.placeholder_url(&ctx.jid()); ctx.render("Presence/presence.html", json!({ "picture": picture })) } pub fn build_bottom(ctx: &Ctx, page: &str) -> String { let picture = ctx.placeholder_url(&ctx.jid()); ctx.render("BottomNavigation/bottomnavigation.html", json!({ "page": page, "picture": picture })) } pub async fn build_shortcuts(ctx: &Ctx, page: &str) -> String { let counter = if ctx.is_logged() { notif::unreads(ctx).await } else { 0 }; ctx.render("Shortcuts/shortcuts.html", json!({ "page": page, "counter": counter })) } async fn prepare_presence(ctx: &Ctx) -> String { let me = contact_view(ctx, &ctx.jid()).await; ctx.render("Presence/_presence.html", json!({ "contact": me, "presence": own_presence_key(ctx) })) } async fn prepare_me_bottom(ctx: &Ctx) -> String { let me = contact_view(ctx, &ctx.jid()).await; ctx.render( "BottomNavigation/_bottomnavigation_me.html", json!({ "picture": me.picture, "presence": own_presence_key(ctx) }), ) } pub async fn on_my_presence(ctx: &Ctx) { rpc!(ctx, "MovimTpl.fill", "#presence_widget", prepare_presence(ctx).await); rpc!(ctx, "MovimTpl.fill", "#bottomnavigation_me", prepare_me_bottom(ctx).await); } pub async fn refresh_shortcuts(ctx: &Ctx) { let me = ctx.jid(); let keys: Vec<(String, i64)> = ctx .st(|st| st.notifs.iter().rev().filter_map(|(k, v)| k.strip_prefix("chat|").map(|j| (j.to_string(), *v))).collect()) .unwrap_or_default(); let mut items = vec![]; for (jid, _) in keys.into_iter().take(3) { let conference = ctx.st(|st| st.conferences.get(&jid).cloned()).flatten(); match conference { Some(c) => items.push(json!({ "jid": jid, "muc": true, "name": c.title(), "picture": super::rooms::room_picture(ctx, &c), "counter": 0, })), None => { let counter = db::unreads_count(ctx.db(), &me, &jid, false).await.unwrap_or(0); if counter > 0 { let c = contact_view(ctx, &jid).await; items.push(json!({ "jid": jid, "muc": false, "name": c.truename, "picture": c.picture, "counter": counter })); } } } } if !items.is_empty() { rpc!(ctx, "MovimTpl.fill", "#shortcuts_widget", ctx.render("Shortcuts/_shortcuts.html", json!({ "shortcuts": items }))); } } pub async fn call(ctx: &Ctx, widget: &str, func: &str, p: &P<'_>) -> bool { if !ctx.is_logged() { return false; } match (widget, func) { ("Presence", "ajaxAskLogout") => { super::dialog(ctx, &ctx.render("Presence/_presence_logout.html", json!({})), false, false); } ("Presence", "ajaxLogout") => { rpc!(ctx, "Presence.clearQuick"); let _ = db::encrypted_passwords_clear(ctx.db(), &ctx.jid()).await; logout(ctx).await; } ("Presence", "ajaxHttpGetPresence") => { rpc!(ctx, "MovimTpl.fill", "#presence_widget", prepare_presence(ctx).await); } ("Presence", "ajaxHttpMenu") => { let me = contact_view(ctx, &ctx.jid()).await; let presences: Vec<_> = [("online", "presence.online", "check_circle"), ("away", "presence.away", "schedule"), ("dnd", "presence.dnd", "do_not_disturb_on"), ("xa", "presence.xa", "bedtime")] .iter() .map(|(v, k, icon)| json!({ "value": v, "label": ctx.t(k, &[]), "icon": icon })) .collect(); let html = ctx.render( "Presence/_presence_menu.html", json!({ "contact": me, "presence": own_presence_key(ctx), "presences": presences }), ); super::drawer(ctx, "menu", &html, false, true); } ("Presence", "ajaxSetPresence") => { let value = p.str(0); let show = match value.as_str() { "away" | "dnd" | "xa" => Some(value.clone()), _ => None, }; ctx.st(|st| st.own_show = show); send_presence(ctx, None).await; on_my_presence(ctx).await; } ("Presence", "ajaxProfileRefresh") => { let me = ctx.jid(); crate::pep::refresh_profile(ctx, &me).await; } ("Presence", "ajaxBookmarksGet") => super::rooms::fetch_bookmarks(ctx).await, ("Presence", "ajaxServerCapsGet") | ("Presence", "ajaxServerDisco") => discover_server(ctx).await, ("Presence", _) => {} ("BottomNavigation", "ajaxHttpRefresh") => { rpc!(ctx, "BottomNavigation.setChatNotification", json!({ "chat": notif::unreads(ctx).await })); rpc!(ctx, "MovimTpl.fill", "#bottomnavigation_me", prepare_me_bottom(ctx).await); } ("Navigation", "ajaxHttpRefresh") => { rpc!(ctx, "MovimUtils.setDataItem", "#chatcounter", "counter", notif::unreads(ctx).await); } ("Shortcuts", "ajaxGet") => refresh_shortcuts(ctx).await, _ => return false, } true } pub async fn send_presence(ctx: &Ctx, to: Option<&str>) { let (show, status) = ctx.st(|st| (st.own_show.clone(), st.own_status.clone())).unwrap_or((None, None)); let show = show.or_else(|| Some("chat".into())); let mut p = proto::presence(show.as_deref(), status.as_deref(), 0); if let Some(to) = to { use crate::xmpp::ElementExt; p.set_a("to", to); } ctx.send(p); ctx.st(|st| st.session_down = false); // Refresh our presence in the joined rooms if to.is_none() { for room in ctx.st(|st| st.joined_rooms()).unwrap_or_default() { if let Some(nick) = super::rooms::my_nick(ctx, &room) { let mut p = proto::presence(show.as_deref(), status.as_deref(), 0); use crate::xmpp::ElementExt; p.set_a("to", format!("{room}/{nick}")); if let Some(muji) = super::visio::own_muji(ctx, &room) { p.append_child(muji); } ctx.send(p); } } } } /// No browser tab is connected anymore: away with XEP-0319 idle pub async fn send_away(ctx: &Ctx) { let status = ctx.st(|st| st.own_status.clone()).flatten(); let mut p = proto::presence(Some("away"), status.as_deref(), 0); let since = (chrono::Utc::now() - chrono::Duration::seconds(10)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); p.append_child(xmpp::el("idle", proto::ns::IDLE).a_since(since)); ctx.send(p); ctx.st(|st| st.session_down = true); } trait SinceExt { fn a_since(self, since: String) -> minidom::Element; } impl SinceExt for minidom::element::ElementBuilder { fn a_since(self, since: String) -> minidom::Element { use crate::xmpp::BuilderExt; self.a("since", since).build() } } pub async fn logout(ctx: &Ctx) { ctx.st(|st| st.logging_out = true); if let Some(x) = ctx.xmpp() { super::visio::terminate_current(ctx).await; let unavailable = xmpp::el("presence", xmpp::NS_CLIENT).build(); let mut unavailable = unavailable; use crate::xmpp::ElementExt; unavailable.set_a("type", "unavailable"); x.send(unavailable); x.end(); } else if let Some(s) = &ctx.session { // Not connected: just forget the session s.close_clients(); ctx.app.hub.remove(&s.sid); } } /// Everything done after a successful authentication, like `Presence::start()` pub async fn start_session(ctx: &Ctx, conn: xmpp::Connection) { let Some(session) = ctx.session.clone() else { return }; let x = conn.handle.clone(); session.set_xmpp(Some(conn.handle.clone())); { let mut st = session.st.lock().unwrap(); st.logged = true; st.resource = x.jid.resource().to_string(); } crate::handler::spawn(ctx.app.clone(), session.clone(), conn.incoming); let ws = ctx.to_ws(); rpc!(ws, "Notif.inhibit", 15); // Roster first, so the interface is complete on the first display if let Ok(res) = x.iq(proto::roster_get()).await { if let Some(q) = res.get_child("query", proto::ns::ROSTER) { for item in q.children().filter(|c| c.name() == "item") { crate::handler::roster_item(&ws, item).await; } } } // Carbons, blocklist and server capabilities x.iq_spawn(proto::carbons_enable()); if let Ok(res) = x.iq(proto::blocklist_get()).await { if let Some(list) = res.get_child("blocklist", proto::ns::BLOCKING) { let items: Vec = list .children() .filter(|c| c.name() == "item") .filter_map(|c| c.attr("jid").map(String::from)) .collect(); ws.st(|st| st.blocked = items.into_iter().collect()); } } send_presence(&ws, None).await; let ctx2 = ws.clone(); tokio::spawn(async move { discover_server(&ctx2).await; super::rooms::fetch_bookmarks(&ctx2).await; let me = ctx2.jid(); crate::pep::refresh_profile(&ctx2, &me).await; crate::mam::catch_up(&ctx2).await; super::chatomemo::refresh_own_devices(&ctx2).await; }); } /// Disco the server and the account: features, HTTP upload service, external services pub async fn discover_server(ctx: &Ctx) { let Some(x) = ctx.xmpp() else { return }; let me = ctx.jid(); let host = me.split('@').nth(1).unwrap_or("").to_string(); if let Ok(res) = x.iq(proto::disco_info(&host, None)).await { let features = disco_features(&res); ctx.st(|st| st.server_features = features.into_iter().collect()); } if let Ok(res) = x.iq(proto::disco_info(&me, None)).await { let features = disco_features(&res); ctx.st(|st| st.account_features = features.into_iter().collect()); } if let Ok(res) = x.iq(proto::disco_items(&host, None)).await { let items: Vec = res .get_child("query", proto::ns::DISCO_ITEMS) .map(|q| q.children().filter_map(|i| i.attr("jid").map(String::from)).collect()) .unwrap_or_default(); for item in items { let Ok(info) = x.iq(proto::disco_info(&item, None)).await else { continue }; let features = disco_features(&info); if features.iter().any(|f| f == proto::ns::UPLOAD) { let max = max_file_size(&info); ctx.st(|st| st.upload = Some(crate::session::UploadService { jid: item.clone(), max_size: max })); } let is_muc = info .get_child("query", proto::ns::DISCO_INFO) .is_some_and(|q| q.children().any(|i| i.name() == "identity" && i.attr("category") == Some("conference") && i.attr("type") == Some("text"))); if is_muc { ctx.st(|st| st.caps.insert(format!("muc_service:{item}"), (None, vec![]))); } } } } pub fn disco_features(res: &minidom::Element) -> Vec { res.get_child("query", proto::ns::DISCO_INFO) .map(|q| { q.children() .filter(|c| c.name() == "feature") .filter_map(|c| c.attr("var").map(String::from)) .collect() }) .unwrap_or_default() } fn max_file_size(res: &minidom::Element) -> Option { let q = res.get_child("query", proto::ns::DISCO_INFO)?; for x in q.children().filter(|c| c.name() == "x" && c.ns() == proto::ns::DATA) { let is_upload = x.children().any(|f| { f.attr("var") == Some("FORM_TYPE") && f.children().any(|v| v.text() == proto::ns::UPLOAD) }); if is_upload { for f in x.children().filter(|f| f.attr("var") == Some("max-file-size")) { if let Some(v) = f.children().next() { return v.text().parse().ok(); } } } } None }