init
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user