319 lines
12 KiB
Rust
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
|
|
}
|