Files
movim-rs/src/widgets/presence.rs
T
2026-10-24 14:03:52 +07:00

319 lines
12 KiB
Rust

//! 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<String> = 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<String> = 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<String> {
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<u64> {
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
}