285 lines
12 KiB
Rust
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 && !has_file && !m.retracted;
|
|
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);
|
|
}
|