//! ChatActions widget: actions on a message (react, copy, reply, edit, //! retract, moderate), search in a discussion, shared files, URL previews. use serde_json::json; use super::{chat, contactactions, rooms, P}; use crate::{app::Ctx, db, proto, rpc, utils}; pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool { match func { "ajaxGetContact" => contactactions::get_drawer(ctx, &p.str(0)).await, "ajaxBlock" => contactactions::block(ctx, &p.str(0), true).await, "ajaxUnblock" => contactactions::block(ctx, &p.str(0), false).await, "ajaxShowMessageDialog" => show_message_dialog(ctx, p.i(0).unwrap_or(0)).await, "ajaxShowSearchDialog" => { let html = ctx.render( "ChatActions/_chatactions_search.html", json!({ "jid": p.str(0), "muc": p.b(1), "placeholder": ctx.render("ChatActions/_chatactions_search_placeholder.html", json!({})), }), ); super::drawer(ctx, "chat_search", &html, false, false); send_encrypted_index(ctx, &p.str(0), p.b(1)).await; rpc!(ctx, "ChatActions.focusSearch"); } "ajaxSearchMessages" => { let encrypted: Vec = p.v(3).and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|m| m.as_i64()).collect()).unwrap_or_default(); search(ctx, &p.str(0), &p.str(1), p.b(2), &encrypted).await; } "ajaxShowFiles" => show_files(ctx, &p.str(0), p.b(1)).await, "ajaxCopiedMessageText" => ctx.toast(&ctx.t("chatactions.copied_text", &[])), "ajaxHttpDaemonRetract" => retract(ctx, p.i(0).unwrap_or(0)).await, "ajaxHttpDaemonModerate" => moderate(ctx, p.i(0).unwrap_or(0)).await, "ajaxResolveMessage" => resolve_message(ctx, p.i(0).unwrap_or(0)).await, "ajaxResolveUrl" => { let url = p.str(0); if let Some(html) = super::preview::embed_html(ctx, &url).await { rpc!(ctx, "MovimTpl.fill", "#embed", html); } rpc!(ctx, "Chat.finishedSending"); rpc!(ctx, "Chat.disableSending"); } "ajaxEditMessage" | "ajaxEnableWideConferenceCall" => {} _ => return false, } true } async fn prepare_message(ctx: &Ctx, m: &db::Message, search: bool, headline: Option) -> String { let me = ctx.jid(); let file = db::message_file(ctx.db(), m.mid).await.ok().flatten(); let picture = file .as_ref() .filter(|f| utils::type_is_picture(f.type_.as_deref().unwrap_or(""))) .map(|f| format!("{}picture?url={}", ctx.base_uri(), percent_encoding::utf8_percent_encode(&f.url, percent_encoding::NON_ALPHANUMERIC))); let body = headline.unwrap_or_else(|| chat::format_body(ctx, m.body.as_deref().unwrap_or("").trim()).0); let mine = if m.is_muc() { rooms::my_nick(ctx, &m.jidfrom) == m.resource } else { m.jidfrom == me }; let date = if search { ctx.prepare_date(&m.published, false, true) } else { ctx.prepare_time(&m.published) }; ctx.render( "ChatActions/_chatactions_message.html", json!({ "message": m, "jid": m.jid(), "mine": mine, "muc": m.is_muc(), "color": m.color(), "picture": picture, "body": body, "date": date, }), ) } async fn show_message_dialog(ctx: &Ctx, mid: i64) { let me = ctx.jid(); let Some(m) = db::message_by_mid(ctx.db(), &me, mid).await.ok().flatten() else { return }; if !m.is_classic() || m.retracted { return; } let preview = prepare_message(ctx, &m, false, None).await; let reactions = db::reactions_for(ctx.db(), &[mid]).await.unwrap_or_default(); let mut merged: Vec<(String, Vec)> = vec![]; for r in reactions { let who = if m.is_muc() { r.jidfrom.clone() } else { super::contact_view(ctx, &r.jidfrom).await.truename }; match merged.iter_mut().find(|(e, _)| *e == r.emoji) { Some((_, f)) => f.push(who), None => merged.push((r.emoji.clone(), vec![who])), } } let reactions: Vec<_> = merged.into_iter().map(|(e, f)| json!({ "emoji": e, "from": f })).collect(); let mine = if m.is_muc() { rooms::my_nick(ctx, &m.jidfrom) == m.resource && m.resource.is_some() } else { m.jidfrom == me }; let has_file = db::message_file(ctx.db(), mid).await.ok().flatten().is_some(); let editable = mine && !m.encrypted && !has_file; let moderator = m.is_muc() && m.stanzaid.is_some() && ctx .st(|st| { st.muc_self(&m.jidfrom).is_some_and(|p| p.role == "moderator") && st.conferences.get(&m.jidfrom).is_some_and(|c| c.features.iter().any(|f| f.starts_with("urn:xmpp:message-moderate"))) }) .unwrap_or(false); let mut for_js = serde_json::to_value(&m).unwrap_or(json!({})); for_js["body"] = json!(utils::html_escape(m.body.as_deref().unwrap_or(""))); rpc!(ctx, "ChatActions.setMessage", for_js); let html = ctx.render( "ChatActions/_chatactions_message_dialog.html", json!({ "message": m, "preview": preview, "reactions": reactions, "mine": mine, "editable": editable, "moderator": moderator, }), ); super::dialog(ctx, &html, false, false); } /// The browser searches and lists the OMEMO messages itself (the server /// cannot read them): it gets their ids, and looks the decrypted bodies up async fn send_encrypted_index(ctx: &Ctx, jid: &str, muc: bool) { if !utils::validate_jid(jid) || !ctx.me().is_some_and(|u| u.omemoenabled) { return; } let index = db::encrypted_index(ctx.db(), &ctx.jid(), jid, muc).await.unwrap_or_default(); rpc!(ctx, "ChatActions.setIndex", jid, index); } async fn show_files(ctx: &Ctx, jid: &str, muc: bool) { if !utils::validate_jid(jid) { return; } let units = [ ctx.t("filesize.byte", &[]), ctx.t("filesize.kilobyte", &[]), ctx.t("filesize.megabyte", &[]), ctx.t("filesize.gigabyte", &[]), ctx.t("filesize.terabyte", &[]), ]; let (mut images, mut videos, mut recordings, mut files) = (vec![], vec![], vec![], vec![]); for f in db::conversation_files(ctx.db(), &ctx.jid(), jid, muc, 500).await.unwrap_or_default() { // Opened by the browser: never a javascript: or data: URL if !f.url.starts_with("https://") && !f.url.starts_with("http://") { continue; } let type_ = f.type_.clone().unwrap_or_default(); let item = json!({ "mid": f.mid, "url": f.url, "name": f.name.clone().unwrap_or_else(|| f.url.rsplit('/').next().unwrap_or("").to_string()), "size": f.size.map(|s| utils::human_size(s, &units)), "date": ctx.prepare_date(&f.published, false, false), "published": f.published, "thumb": format!("{}picture?url={}", ctx.base_uri(), percent_encoding::utf8_percent_encode(&f.url, percent_encoding::NON_ALPHANUMERIC)), }); if utils::type_is_picture(&type_) { images.push(item); } else if type_.starts_with("video/") { videos.push(item); } else if type_.starts_with("audio/") { recordings.push(item); } else { files.push(item); } } let html = ctx.render( "ChatActions/_chatactions_files.html", json!({ "jid": jid, "images": images, "videos": videos, "recordings": recordings, "files": files }), ); super::drawer(ctx, "chat_files", &html, false, false); send_encrypted_index(ctx, jid, muc).await; rpc!(ctx, "ChatActions.addEncryptedFiles", jid); } async fn search(ctx: &Ctx, jid: &str, keywords: &str, muc: bool, encrypted: &[i64]) { if !utils::validate_jid(jid) { return; } let keywords = keywords.trim().to_lowercase(); if keywords.is_empty() { rpc!(ctx, "MovimTpl.fill", "#chat_search", ctx.render("ChatActions/_chatactions_search_placeholder.html", json!({}))); return; } let me = ctx.jid(); let messages = db::messages_search(ctx.db(), &me, jid, muc, &keywords, encrypted, 20).await.unwrap_or_default(); let mut list = vec![]; for m in &messages { let body = m.body.as_deref().unwrap_or(""); let escaped = utils::html_escape(body); let highlighted = highlight(&escaped, &utils::html_escape(&keywords)); list.push(prepare_message(ctx, m, true, Some(highlighted)).await); } rpc!(ctx, "MovimTpl.fill", "#chat_search", ctx.render("ChatActions/_chatactions_search_result.html", json!({ "messages": list }))); rpc!(ctx, "ChatActions.fillEncrypted"); } fn highlight(text: &str, keywords: &str) -> String { let lower = text.to_lowercase(); let mut out = String::new(); let mut last = 0; for word in keywords.split_whitespace() { let _ = word; } let first = keywords.split_whitespace().next().unwrap_or(""); if first.is_empty() { return text.to_string(); } let mut start = 0; while let Some(pos) = lower[start..].find(first) { let begin = start + pos; let end = begin + first.len(); if !text.is_char_boundary(begin) || !text.is_char_boundary(end) { break; } out.push_str(&text[last..begin]); out.push_str(""); out.push_str(&text[begin..end]); out.push_str(""); last = end; start = end; } out.push_str(&text[last..]); out } async fn retract(ctx: &Ctx, mid: i64) { let me = ctx.jid(); let Some(m) = db::message_by_mid(ctx.db(), &me, mid).await.ok().flatten() else { return }; let muc = m.is_muc(); let id = if muc { m.stanzaid.clone() } else { m.originid.clone().or(m.messageid.clone()) }; let Some(id) = id else { return }; rpc!(ctx, "Dialog.clear"); let to = if muc { m.jidfrom.clone() } else { m.jidto.clone() }; ctx.send(proto::retract(&to, muc, &id)); if !muc { let _ = db::message_retract(ctx.db(), mid).await; chat::refresh_message(ctx, mid).await; super::chats::refresh_item(ctx, &to).await; } } async fn moderate(ctx: &Ctx, mid: i64) { let me = ctx.jid(); let Some(m) = db::message_by_mid(ctx.db(), &me, mid).await.ok().flatten() else { return }; let Some(sid) = m.stanzaid.clone() else { return }; rpc!(ctx, "Dialog.clear"); if let Some(x) = ctx.xmpp() { if let Err(e) = x.iq(proto::moderate(&m.jidfrom, &sid, None)).await { ctx.toast(&e.to_string()); } } } /// Link preview of a message: resolve its first URL, a picture link becomes a picture async fn resolve_message(ctx: &Ctx, mid: i64) { let me = ctx.jid(); let Ok(Some(m)) = db::message_by_mid(ctx.db(), &me, mid).await else { return }; if m.resolved || m.encrypted || m.retracted { return; } let body = m.body.clone().unwrap_or_default(); let Some(url) = utils::find_url(&body) else { return }; let _ = db::message_set_resolved(ctx.db(), mid).await; let Some(info) = super::preview::resolve(&ctx.app, &url).await else { return }; if info.type_ == "image" && body.trim() == url && db::message_file(ctx.db(), mid).await.ok().flatten().is_none() { let file = db::MessageFile { message_mid: mid, name: info.title.clone(), type_: Some(info.content_type.clone()), size: Some(info.content_length), url: url.clone(), ..Default::default() }; let _ = db::message_file_save(ctx.db(), &file).await; } rpc!(ctx, "Chat.refreshMessage", mid); }