edit messages

This commit is contained in:
nnduc
2026-10-24 18:04:12 +07:00
parent a3cf10cb0a
commit 6946109ae2
6 changed files with 210 additions and 41 deletions
+32 -5
View File
@@ -234,6 +234,8 @@ var Chat = {
if (text == '') {
Chat.disableSending();
} else if (Chat.getOmemoState() == 'yes') {
Chat.sendEncryptedCorrection(jid, textarea.dataset.mid, text, isMuc);
} else {
let correct = Chat_ajaxHttpDaemonCorrect(jid, textarea.dataset.mid, text);
correct.then(e => {
@@ -301,6 +303,35 @@ var Chat = {
}
}
},
sendEncryptedCorrection: function (jid, mid, text, isMuc) {
let omemo = ChatOmemo.encrypt(jid, text, isMuc);
if (!omemo) {
Chat.failedMessage(text);
return;
}
omemo.then(omemoheader => {
let id = omemoheader.id = crypto.randomUUID();
Chat.tempMessages[id] = text;
Chat_ajaxHttpDaemonCorrect(jid, mid, null, omemoheader)
.then(e => Chat.sentMessage())
.catch(e => Chat.failedMessage(text));
});
},
// The server cannot read encrypted messages, the text comes from this browser
editEncrypted: function (id, mid) {
if (typeof ChatOmemoDB == 'undefined') return;
ChatOmemoDB.getMessage(id).then(text => {
if (typeof text == 'string' && text != '' && !AESGCM_REGEX.test(text.trim())) {
Chat.setTextarea(text, mid);
} else {
Toast.send(Chat.action_impossible_encrypted_error);
}
});
},
sentId: function (id) {
if (Chat.tempMessages[id]) {
ChatOmemoDB.putMessage(id, Chat.tempMessages[id]);
@@ -405,11 +436,7 @@ var Chat = {
return;
}
if (!Chat.isEncrypted()) {
Chat_ajaxLast(textarea.dataset.jid, Boolean(textarea.dataset.muc));
} else {
Toast.send(Chat.action_impossible_encrypted_error);
}
},
resolveMessage: function (mid) {
ChatActions_ajaxResolveMessage(mid);
@@ -443,7 +470,7 @@ var Chat = {
return;
}
if (event.key == 'ArrowUp' && !Chat.isEncrypted()) {
if (event.key == 'ArrowUp') {
Chat.editPrevious();
} else if (event.key == 'Escape'
&& (this.value == '' || Chat.edit == true)) {
+27 -2
View File
@@ -93,6 +93,7 @@ CREATE INDEX IF NOT EXISTS messages_published ON messages (published);
CREATE INDEX IF NOT EXISTS messages_user_stanzaid ON messages (user_id, stanzaid);
CREATE INDEX IF NOT EXISTS messages_user_originid ON messages (user_id, originid);
CREATE INDEX IF NOT EXISTS messages_user_messageid ON messages (user_id, messageid);
CREATE INDEX IF NOT EXISTS messages_user_replaceid ON messages (user_id, replaceid);
CREATE TABLE IF NOT EXISTS message_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1096,7 +1097,7 @@ pub async fn messages_search(
let rows: Vec<(i64, String)> = sqlx::query_as(AssertSqlSafe(format!(
"SELECT mid, body FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) AND type IN ({types_sql})
AND encrypted = 0 AND retracted = 0 AND body IS NOT NULL ORDER BY published DESC"
AND encrypted = 0 AND retracted = 0 AND body IS NOT NULL AND {NOT_REPLACED} ORDER BY published DESC"
)))
.bind(user)
.bind(jid)
@@ -1131,13 +1132,20 @@ pub async fn messages_search(
Ok(q.bind(limit).fetch_all(db).await?)
}
/// Skips the messages that were corrected by their author (XEP-0308): only the last version is searched
/// (corrections all point to the first version, so a later one hides the earlier ones too)
const NOT_REPLACED: &str = "NOT EXISTS (SELECT 1 FROM messages r WHERE r.user_id = messages.user_id AND r.replaceid IS NOT NULL
AND (r.replaceid IN (messages.id, messages.messageid, messages.originid)
OR r.replaceid = messages.replaceid AND r.mid > messages.mid)
AND r.jidfrom = messages.jidfrom AND (r.type = 'chat' OR r.resource IS messages.resource))";
/// OMEMO messages of a conversation, newest first: (mid, id, messageid, published).
/// The browser looks their decrypted bodies up with id or messageid.
pub async fn encrypted_index(db: &Db, user: &str, jid: &str, muc: bool) -> Result<Vec<(i64, String, Option<String>, String)>> {
let types_sql = conversation_types(muc);
Ok(sqlx::query_as(AssertSqlSafe(format!(
"SELECT mid, id, messageid, published FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)
AND type IN ({types_sql}) AND encrypted = 1 AND retracted = 0 ORDER BY published DESC LIMIT 5000"
AND type IN ({types_sql}) AND encrypted = 1 AND retracted = 0 AND {NOT_REPLACED} ORDER BY published DESC LIMIT 5000"
)))
.bind(user)
.bind(jid)
@@ -1300,6 +1308,23 @@ mod tests {
let index = encrypted_index(&db, me, bob, false).await.unwrap();
assert_eq!(index.iter().map(|e| e.0).collect::<Vec<_>>(), vec![secret_mid]);
// Corrections: only the last version is searched
let typo = Message { id: "e1".into(), messageid: Some("e1".into()), body: Some("hẹn gặp lúc 9h".into()), published: days_ago(1), ..m.clone() };
let fixed = Message { id: "e2".into(), replaceid: Some("e1".into()), body: Some("hẹn gặp lúc 10h".into()), published: days_ago(1), ..m.clone() };
message_save(&db, &typo).await.unwrap();
let fixed_mid = message_save(&db, &fixed).await.unwrap();
assert!(messages_search(&db, me, bob, false, "9h", &[], 20).await.unwrap().is_empty());
let found = messages_search(&db, me, bob, false, "hen gap", &[], 20).await.unwrap();
assert_eq!(found.iter().map(|m| m.mid).collect::<Vec<_>>(), vec![fixed_mid]);
let again = Message { id: "e4".into(), replaceid: Some("e1".into()), body: Some("hẹn gặp lúc 11h".into()), published: days_ago(1), ..m.clone() };
let again_mid = message_save(&db, &again).await.unwrap();
let found = messages_search(&db, me, bob, false, "hen gap", &[], 20).await.unwrap();
assert_eq!(found.iter().map(|m| m.mid).collect::<Vec<_>>(), vec![again_mid]);
// Someone else cannot hide a message with a fake correction
let fake = Message { id: "e3".into(), jidfrom: "eve@example.org".into(), replaceid: Some("s1".into()), published: days_ago(1), ..m.clone() };
message_save(&db, &fake).await.unwrap();
assert_eq!(messages_search(&db, me, bob, false, "xin chao", &[], 20).await.unwrap().len(), 1);
// Expiry: an old message goes, a recent one stays
let old = Message { id: "old".into(), published: "2020-01-01 10:00:00".into(), ..m.clone() };
let new = Message { id: "new".into(), published: crate::utils::now_sql(), ..m.clone() };
+58 -31
View File
@@ -40,13 +40,13 @@ pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
"ajaxGetRoom" => get_room(ctx, &p.str(0), p.b(1)).await,
"ajaxHttpDaemonSendMessage" => send_from_params(ctx, p).await,
"ajaxHttpDaemonCorrect" => {
// chat.js calls it with (to, mid, message)
let (to, mid, message) = if p.i(1).is_some() && p.v(3).is_none() {
(p.str(0), p.i(1).unwrap_or(0), p.str(2))
// chat.js calls it with (to, mid, message, omemo)
let (to, mid, message, omemo) = if p.i(1).is_some() {
(p.str(0), p.i(1).unwrap_or(0), p.str(2), p.v(3).cloned())
} else {
(p.str(1), p.i(2).unwrap_or(0), p.str(3))
(p.str(1), p.i(2).unwrap_or(0), p.str(3), p.v(4).cloned())
};
correct(ctx, &to, mid, &message).await;
correct(ctx, &to, mid, &message, omemo).await;
}
"ajaxHttpDaemonSendReaction" => send_reaction(ctx, p.i(0).unwrap_or(0), &p.str(1)).await,
"ajaxRefreshMessage" => refresh_message(ctx, p.i(0).unwrap_or(0)).await,
@@ -1080,12 +1080,59 @@ fn omemo_element(o: &Value) -> minidom::Element {
.build()
}
async fn correct(ctx: &Ctx, to: &str, mid: i64, message: &str) {
async fn correct(ctx: &Ctx, to: &str, mid: i64, message: &str, omemo: Option<Value>) {
let me = ctx.jid();
let Some(original) = db::message_by_mid(ctx.db(), &me, mid).await.ok().flatten() else { return };
if !is_mine(ctx, &original) {
return;
}
let muc = original.is_muc();
let to = if to.is_empty() || to == "false" { original.jid().to_string() } else { to.to_string() };
send_message(ctx, &utils::generate_uuid(), &to, Some(message.to_string()), muc, None, None, Some(original), None).await;
// Encrypted corrections keep the id chosen by the browser, it stores the plaintext under it
let id = omemo
.as_ref()
.and_then(|o| o.get("id"))
.and_then(|i| i.as_str())
.map(String::from)
.unwrap_or_else(utils::generate_uuid);
send_message(ctx, &id, &to, Some(message.to_string()), muc, None, None, Some(original), omemo).await;
}
fn is_mine(ctx: &Ctx, m: &Message) -> bool {
if m.is_muc() {
m.resource.is_some() && rooms::my_nick(ctx, &m.jidfrom) == m.resource
} else {
m.jidfrom == ctx.jid()
}
}
/// Corrections always point to the first version of a message (XEP-0308)
async fn original_mid(ctx: &Ctx, m: &Message) -> i64 {
if let Some(rid) = &m.replaceid {
if let Ok(Some(orig)) = db::message_resolve_reference(ctx.db(), &ctx.jid(), m.jid(), rid).await {
return orig.mid;
}
}
m.mid
}
/// Put the latest version of one of my messages in the textarea to correct it
async fn start_edit(ctx: &Ctx, m: Message) {
if !is_mine(ctx, &m) || m.retracted || m.type_ != "chat" && m.type_ != "groupchat" {
return;
}
if db::message_file(ctx.db(), m.mid).await.ok().flatten().is_some() {
return;
}
let mid = original_mid(ctx, &m).await;
if m.encrypted {
// Only the browser can read it
if let Some(id) = m.messageid.clone().or(m.originid.clone()) {
rpc!(ctx, "Chat.editEncrypted", id, mid);
}
} else if let Some(body) = m.body.filter(|b| !b.is_empty()) {
rpc!(ctx, "Chat.setTextarea", body, mid);
}
}
async fn send_reaction(ctx: &Ctx, mid: i64, emoji: &str) {
@@ -1135,36 +1182,16 @@ async fn send_reaction(ctx: &Ctx, mid: i64, emoji: &str) {
async fn last(ctx: &Ctx, to: &str, muc: bool) {
let me = ctx.jid();
let messages = db::messages_for(ctx.db(), &me, to, muc, None, None, 20).await.unwrap_or_default();
let my_nick = if muc { rooms::my_nick(ctx, to) } else { None };
let mine = messages.into_iter().find(|m| {
if muc { m.resource.is_some() && m.resource == my_nick } else { m.jidfrom == me }
});
let Some(m) = mine else { return };
if m.retracted || m.encrypted || m.type_ != "chat" && m.type_ != "groupchat" {
return;
}
if db::message_file(ctx.db(), m.mid).await.ok().flatten().is_some() {
return;
}
// Use the original message id so the correction replaces it
let mut mid = m.mid;
if let Some(rid) = &m.replaceid {
if let Ok(Some(orig)) = db::message_resolve_reference(ctx.db(), &me, to, rid).await {
mid = orig.mid;
}
}
if let Some(body) = m.body.filter(|b| !b.is_empty()) {
rpc!(ctx, "Chat.setTextarea", body, mid);
if let Some(m) = messages.into_iter().find(|m| is_mine(ctx, m)) {
start_edit(ctx, m).await;
}
}
async fn edit(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 db::message_file(ctx.db(), mid).await.ok().flatten().is_some() {
return;
if let Some(m) = db::message_by_mid(ctx.db(), &me, mid).await.ok().flatten() {
start_edit(ctx, m).await;
}
rpc!(ctx, "Chat.setTextarea", m.body.unwrap_or_default(), mid);
}
async fn reply(ctx: &Ctx, mid: i64) {
+1 -1
View File
@@ -94,7 +94,7 @@ async fn show_message_dialog(ctx: &Ctx, mid: i64) {
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 editable = mine && !has_file && !m.retracted;
let moderator = m.is_muc()
&& m.stanzaid.is_some()
&& ctx
+1 -1
View File
@@ -131,7 +131,7 @@ pub static WIDGETS: &[WidgetDef] = &[
("ajaxGetRoom", &["room", "noConnect"]),
("ajaxGetSpaceRoom", &["server", "node", "roomId"]),
("ajaxHttpDaemonSendMessage", &["id", "to", "message", "muc", "file", "replyToMid", "mucReceipts", "omemo"]),
("ajaxHttpDaemonCorrect", &["id", "to", "mid", "message"]),
("ajaxHttpDaemonCorrect", &["id", "to", "mid", "message", "omemo"]),
("ajaxHttpDaemonSendReaction", &["mid", "emoji"]),
("ajaxRefreshMessage", &["mid"]),
("ajaxLast", &["to", "muc"]),
+90
View File
@@ -0,0 +1,90 @@
"""Message corrections (XEP-0308), in clear and in OMEMO chats."""
import asyncio
from playwright.async_api import async_playwright
from browser import login, BASE, OUT
async def send(page, text):
await page.fill("#chat_textarea", text)
await page.press("#chat_textarea", "Enter")
await page.wait_for_timeout(2500)
async def edit_last(page, text):
"""↑ in the empty textarea, then replace the text"""
await page.fill("#chat_textarea", "")
await page.press("#chat_textarea", "ArrowUp")
await page.wait_for_timeout(1500)
print(" textarea after ↑:", repr(await page.input_value("#chat_textarea")))
await send(page, text)
async def bubbles(page, n=4):
return (await page.locator("#chat_widget .bubble p").all_inner_texts())[-n:]
async def open_chat(page, jid):
await page.goto(BASE + "/chat/" + jid)
await page.wait_for_selector("#chat_textarea", timeout=15000)
await page.wait_for_timeout(5000)
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
actx, alice, aerr = await login(browser, "alice@localhost", "alicepass", "editalice")
bctx, bob, berr = await login(browser, "bob@localhost", "bobpass", "editbob")
await alice.wait_for_timeout(4000)
await open_chat(alice, "bob@localhost")
await open_chat(bob, "alice@localhost")
# Clear text
await alice.evaluate("ChatOmemo.disableContactState('bob@localhost')")
await alice.wait_for_timeout(2000)
await send(alice, "Hẹn gặp lúc 9h")
await edit_last(alice, "Hẹn gặp lúc 10h")
print("clear, alice:", await bubbles(alice, 2))
print("clear, bob:", await bubbles(bob, 2))
print("edited marks on bob:", await bob.locator("#chat_widget .edited").count())
# OMEMO
await alice.evaluate("ChatOmemo.enableContactState('bob@localhost', false)")
await alice.wait_for_timeout(5000)
print("alice omemo:", await alice.evaluate("Chat.getOmemoState()"))
await send(alice, "Bí mật: mật khẩu wifi là 1234")
await edit_last(alice, "Bí mật: mật khẩu wifi là 5678")
await alice.screenshot(path=f"{OUT}/edit_1_alice.png")
print("omemo, alice:", await bubbles(alice, 3))
print("omemo, bob:", await bubbles(bob, 3))
# Second edit, from the message menu
mid = await alice.evaluate("[...document.querySelectorAll('#chat_widget .bubble [data-mid]')].pop().dataset.mid")
await alice.evaluate(f"Chat_ajaxEdit({mid})")
await alice.wait_for_timeout(1500)
print(" textarea from menu:", repr(await alice.input_value("#chat_textarea")))
await send(alice, "Bí mật: mật khẩu wifi là 9999")
await bob.wait_for_timeout(2000)
await bob.screenshot(path=f"{OUT}/edit_2_bob.png")
print("omemo 2nd edit, bob:", await bubbles(bob, 3))
# After a reload only the last versions are shown
await open_chat(bob, "alice@localhost")
await open_chat(alice, "bob@localhost")
print("reload, bob:", await bubbles(bob, 3))
print("reload, alice:", await bubbles(alice, 3))
await bob.screenshot(path=f"{OUT}/edit_3_bob_reload.png")
# Search finds the new text, not the old one
await bob.evaluate("ChatActions_ajaxShowSearchDialog('alice@localhost', false)")
await bob.wait_for_selector("form[name=search] input", timeout=10000)
await bob.wait_for_timeout(1000)
for q in ("wifi", "1234", "9h", "10h"):
await bob.fill("form[name=search] input", q)
await bob.wait_for_timeout(2500)
print(f"search {q!r}:", await bob.locator("#chat_search #message_preview li p").all_inner_texts())
for e in aerr + berr:
print(e)
await browser.close()
asyncio.run(main())