//! Chats widget: the list of open 1:1 conversations. use serde_json::{json, Value}; use super::{chat, contact_view, notif, P}; use crate::{app::Ctx, db, handler::Origin, rpc, utils}; const FILTERS: &[&str] = &["all", "roster"]; pub fn build(ctx: &Ctx) -> String { let filter = ctx.st(|st| st.chats_filter.clone()).unwrap_or_else(|| "all".into()); ctx.render( "Chats/chats.html", json!({ "filters": FILTERS, "filter": filter, "addplaceholder": ctx.t("chats.empty", &["chat_add_on"]), }), ) } fn item_id(jid: &str) -> String { format!("#{}", utils::cleanup_id(&format!("{}_chat_item", utils::slugify(jid)))) } /// Put the item of a discussion on top of the list. The html is prepared before /// touching the list: with an await between the remove and the prepend, two /// concurrent calls (a message during a reload) left two items for one contact. async fn put_on_top(ctx: &Ctx, jid: &str) { let html = prepare_chat(ctx, jid).await; rpc!(ctx, "MovimTpl.remove", item_id(jid)); rpc!(ctx, "MovimTpl.prepend", "#chats", html); } pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool { match func { "ajaxHttpGet" => { rpc!(ctx, "MovimTpl.fill", "#chats", prepare_chats(ctx).await); rpc!(ctx, "Chats.refresh"); } "ajaxGetMAMHistory" => { let jid = p.s(0); let ctx = ctx.to_ws(); tokio::spawn(async move { match jid { Some(jid) => { crate::mam::fetch_history(&ctx, &jid, false).await; } None => crate::mam::catch_up(&ctx).await, } }); } "ajaxSetFilter" => { let filter = p.str(0); if FILTERS.contains(&filter.as_str()) { ctx.st(|st| st.chats_filter = filter.clone()); } rpc!(ctx, "Chats.refreshFilters"); } "ajaxOpen" => { let and_show = p.b(1); let history = p.v(2).is_none() || p.b(2); open(ctx, &p.str(0), and_show, history).await; } "ajaxClose" => close(ctx, &p.str(0), p.b(1)).await, _ => return false, } true } pub async fn prepare_chats(ctx: &Ctx) -> String { let me = ctx.jid(); let chats = db::open_chats(ctx.db(), &me).await.unwrap_or_default(); let mut html = String::new(); for jid in chats.iter().rev() { if utils::validate_jid(jid) { html.push_str(&prepare_chat(ctx, jid).await); } } html } pub async fn prepare_chat(ctx: &Ctx, jid: &str) -> String { let me = ctx.jid(); let (bare, resource) = match jid.split_once('/') { Some((b, r)) => (b.to_string(), Some(r.to_string())), None => (jid.to_string(), None), }; let contact = contact_view(ctx, jid).await; let count = db::unreads_count(ctx.db(), &me, jid, false).await.unwrap_or(0); let last = db::last_message(ctx.db(), &me, jid, false).await.ok().flatten(); let composing = ctx.st(|st| st.chat_states.get(jid).is_some_and(|s| !s.is_empty())).unwrap_or(false); let message = match last { Some(m) => { let file = db::message_file(ctx.db(), m.mid).await.ok().flatten().map(|f| { let t = f.type_.clone().unwrap_or_default(); json!({ "isPicture": utils::type_is_picture(&t), "isAudio": utils::type_is_audio(&t), "isVideo": utils::type_is_video(&t), "thumbnail_type": f.thumbnail_type, "thumbnail_url": f.thumbnail_url, }) }); let body_html = m .body .as_deref() .map(|b| ctx.emoji(&utils::html_escape(&b.replace('\n', " "))).html) .unwrap_or_default(); json!({ "date": ctx.prepare_date(&m.published, true, true), "date_full": ctx.prepare_date(&m.published, false, true), "mine": m.jidfrom == me, "displayed": m.displayed, "delivered": m.delivered, "encrypted": m.encrypted, "retracted": m.retracted, "type": m.type_, "file": file, "body": m.body, "body_html": body_html, }) } None => Value::Null, }; ctx.render( "Chats/_chats_item.html", json!({ "contact": contact, "count": count, "message": message, "composing": composing, "resource": resource, "bare": bare, "in_call": super::visio::is_in_call(ctx, jid), }), ) } /// The discussion was opened: refresh its item pub async fn chat_open(ctx: &Ctx, jid: &str) { if !utils::validate_jid(jid) { return; } let me = ctx.jid(); if !db::open_chat_exists(ctx.db(), &me, jid).await.unwrap_or(false) { let _ = db::open_chat_touch(ctx.db(), &me, jid, None).await; put_on_top(ctx, jid).await; } else { rpc!(ctx, "MovimTpl.replace", item_id(jid), prepare_chat(ctx, jid).await); } rpc!(ctx, "MovimUtils.addClass", item_id(jid), "active"); rpc!(ctx, "Chats.refresh"); } pub async fn refresh_item(ctx: &Ctx, jid: &str) { let me = ctx.jid(); if db::open_chat_exists(ctx.db(), &me, jid).await.unwrap_or(false) { rpc!(ctx, "MovimTpl.replace", item_id(jid), prepare_chat(ctx, jid).await); rpc!(ctx, "Chats.refresh"); if notif::is_current(ctx, jid) { rpc!(ctx, "MovimUtils.addClass", item_id(jid), "active"); } } } pub async fn open(ctx: &Ctx, jid: &str, and_show: bool, history: bool) { let me = ctx.jid(); if !utils::validate_jid(jid) { return; } if history { let ctx2 = ctx.to_ws(); let jid2 = jid.to_string(); tokio::spawn(async move { crate::mam::fetch_history(&ctx2, &jid2, false).await; }); } let _ = db::open_chat_touch(ctx.db(), &me, jid, None).await; put_on_top(ctx, jid).await; rpc!(ctx, "Chats.refresh"); if notif::is_current(ctx, jid) || and_show { rpc!(ctx, "Chats.setActive", jid); } if and_show { rpc!(ctx, "Chat.get", jid); } } pub async fn close(ctx: &Ctx, jid: &str, close_discussion: bool) { if !utils::validate_jid(jid) { return; } let me = ctx.jid(); let _ = db::open_chat_close(ctx.db(), &me, jid).await; let _ = db::messages_mark_seen(ctx.db(), &me, jid).await; rpc!(ctx, "MovimTpl.remove", item_id(jid)); rpc!(ctx, "Chats.refresh"); notif::on_chat_counter(ctx).await; if close_discussion { rpc!(ctx, "Chat_ajaxGet"); } notif::clear(ctx, &format!("chat|{jid}")).await; } pub async fn on_message(ctx: &Ctx, m: &db::Message, origin: &Origin) { if m.is_muc() { return; } let me = ctx.jid(); let jid = if m.jidfrom == me { m.jidto.clone() } else { m.jidfrom.clone() }; if matches!(origin, Origin::Mam { .. }) && m.seen { // History sync: only refresh an already displayed item refresh_item(ctx, &jid).await; return; } let _ = db::open_chat_touch(ctx.db(), &me, &jid, Some(&m.published)).await; put_on_top(ctx, &jid).await; rpc!(ctx, "Chats.refresh"); if notif::is_current(ctx, &jid) { rpc!(ctx, "Chats.setActive", jid.clone()); } if !m.seen { notif::on_chat_counter(ctx).await; } let _ = chat::message_preview; } pub async fn on_presence(ctx: &Ctx, jid: &str) { refresh_item(ctx, jid).await; } pub async fn on_chat_state(ctx: &Ctx, jid: &str) { let composing = ctx.st(|st| st.chat_states.get(jid).is_some_and(|s| !s.is_empty())).unwrap_or(false); let f = if composing { "MovimUtils.addClass" } else { "MovimUtils.removeClass" }; rpc!(ctx, f, format!("{} span.primary", item_id(jid)), "composing"); }