Files
movim-rs/src/widgets/chatactions.rs
T
2026-10-24 17:46:20 +07:00

285 lines
12 KiB
Rust

//! 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<i64> = 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>) -> 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<String>)> = 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("<mark>");
out.push_str(&text[begin..end]);
out.push_str("</mark>");
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);
}