This commit is contained in:
nnduc
2026-10-24 14:03:52 +07:00
commit 4fe046de58
4790 changed files with 311146 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
//! ChatActions widget: actions on a message (react, copy, reply, edit,
//! retract, moderate), search in a discussion, 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);
rpc!(ctx, "ChatActions.focusSearch");
}
"ajaxSearchMessages" => search(ctx, &p.str(0), &p.str(1), p.b(2)).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);
}
async fn search(ctx: &Ctx, jid: &str, keywords: &str, muc: bool) {
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, 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 })));
}
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);
}