notes to self

This commit is contained in:
nnduc
2026-10-29 13:17:40 +07:00
parent 1ce93d883b
commit 5f37fcfe7a
4 changed files with 72 additions and 25 deletions
+68 -19
View File
@@ -785,11 +785,15 @@ pub async fn message_by_id(db: &Db, user: &str, id: &str) -> Result<Option<Messa
Ok(on!(db, p => sqlx::query_as::<_, Message>(sql).bind(user).bind(id).fetch_optional(p).await?)) Ok(on!(db, p => sqlx::query_as::<_, Message>(sql).bind(user).bind(id).fetch_optional(p).await?))
} }
/// Messages exchanged with `jid` (bound three times). With our own JID, only
/// the notes sent to ourself, not everything we sent or received.
const WITH_JID: &str = "(jidfrom = ? OR jidto = ?) AND (jidfrom = jidto OR ? != user_id)";
/// Resolve a message referenced by another one (reply, reaction, retraction, receipt) /// Resolve a message referenced by another one (reply, reaction, retraction, receipt)
/// from the conversation `jid`, by its message id, origin id or stanza id. /// from the conversation `jid`, by its message id, origin id or stanza id.
pub async fn message_resolve_reference(db: &Db, user: &str, jid: &str, reference: &str) -> Result<Option<Message>> { pub async fn message_resolve_reference(db: &Db, user: &str, jid: &str, reference: &str) -> Result<Option<Message>> {
let sql = db.sql(&format!( let sql = db.sql(&format!(
"SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) "SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND {WITH_JID}
AND (messageid = ? OR originid = ? OR stanzaid = ? OR id = ?) ORDER BY mid DESC LIMIT 1" AND (messageid = ? OR originid = ? OR stanzaid = ? OR id = ?) ORDER BY mid DESC LIMIT 1"
)); ));
Ok(on!(db, p => { Ok(on!(db, p => {
@@ -797,6 +801,7 @@ pub async fn message_resolve_reference(db: &Db, user: &str, jid: &str, reference
.bind(user) .bind(user)
.bind(jid) .bind(jid)
.bind(jid) .bind(jid)
.bind(jid)
.bind(reference) .bind(reference)
.bind(reference) .bind(reference)
.bind(reference) .bind(reference)
@@ -903,7 +908,7 @@ pub async fn messages_for(
let types = if muc { MESSAGE_TYPE_MUC } else { MESSAGE_TYPE }; let types = if muc { MESSAGE_TYPE_MUC } else { MESSAGE_TYPE };
let types_sql = types.iter().map(|t| format!("'{t}'")).collect::<Vec<_>>().join(","); let types_sql = types.iter().map(|t| format!("'{t}'")).collect::<Vec<_>>().join(",");
let mut sql = format!( let mut sql = format!(
"SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) AND type IN ({types_sql})" "SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND {WITH_JID} AND type IN ({types_sql})"
); );
if muc { if muc {
sql.push_str(" AND subject IS NULL"); sql.push_str(" AND subject IS NULL");
@@ -919,7 +924,7 @@ pub async fn messages_for(
let sql = db.sql(&sql); let sql = db.sql(&sql);
Ok(on!(db, p => { Ok(on!(db, p => {
let mut q = sqlx::query_as::<_, Message>(sql).bind(user).bind(jid).bind(jid); let mut q = sqlx::query_as::<_, Message>(sql).bind(user).bind(jid).bind(jid).bind(jid);
if let Some(b) = before { if let Some(b) = before {
q = q.bind(b); q = q.bind(b);
} }
@@ -931,8 +936,8 @@ pub async fn messages_for(
} }
pub async fn messages_count(db: &Db, user: &str, jid: &str) -> Result<i64> { pub async fn messages_count(db: &Db, user: &str, jid: &str) -> Result<i64> {
let sql = db.sql("SELECT COUNT(*) FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)"); let sql = db.sql(&format!("SELECT COUNT(*) FROM messages WHERE user_id = ? AND {WITH_JID}"));
Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).bind(jid).bind(jid).fetch_one(p).await?)) Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).bind(jid).bind(jid).bind(jid).fetch_one(p).await?))
} }
/// Clear the local history of a conversation. The archive is marked as fully /// Clear the local history of a conversation. The archive is marked as fully
@@ -942,11 +947,11 @@ pub async fn messages_clear(db: &Db, user: &str, jid: &str, archive_key: &str) -
on!(db, p => { on!(db, p => {
let mut tx = p.begin().await?; let mut tx = p.begin().await?;
for sql in [ for sql in [
"DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?))", format!("DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND {WITH_JID})"),
"DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?))", format!("DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND {WITH_JID})"),
"DELETE FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)", format!("DELETE FROM messages WHERE user_id = ? AND {WITH_JID}"),
] { ] {
sqlx::query(db.sql(sql)).bind(user).bind(jid).bind(jid).execute(&mut *tx).await?; sqlx::query(db.sql(&sql)).bind(user).bind(jid).bind(jid).bind(jid).execute(&mut *tx).await?;
} }
sqlx::query(db.sql( sqlx::query(db.sql(
"INSERT INTO mam_earliests (user_id, jid, earliest) VALUES (?, ?, NULL) "INSERT INTO mam_earliests (user_id, jid, earliest) VALUES (?, ?, NULL)
@@ -1194,8 +1199,8 @@ pub async fn mam_earliest_set(db: &Db, user: &str, jid: &str, earliest: Option<&
} }
pub async fn earliest_message_date(db: &Db, user: &str, jid: &str) -> Result<Option<String>> { pub async fn earliest_message_date(db: &Db, user: &str, jid: &str) -> Result<Option<String>> {
let sql = db.sql("SELECT MIN(published) FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)"); let sql = db.sql(&format!("SELECT MIN(published) FROM messages WHERE user_id = ? AND {WITH_JID}"));
Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).bind(jid).bind(jid).fetch_one(p).await?)) Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).bind(jid).bind(jid).bind(jid).fetch_one(p).await?))
} }
pub async fn latest_message_date(db: &Db, user: &str) -> Result<Option<String>> { pub async fn latest_message_date(db: &Db, user: &str) -> Result<Option<String>> {
@@ -1305,10 +1310,10 @@ pub async fn messages_search(
let words: Vec<String> = crate::utils::search_normalize(keywords).split_whitespace().take(8).map(String::from).collect(); let words: Vec<String> = crate::utils::search_normalize(keywords).split_whitespace().take(8).map(String::from).collect();
let sql = db.sql(&format!( let sql = db.sql(&format!(
"SELECT mid, body FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) AND type IN ({types_sql}) "SELECT mid, body FROM messages WHERE user_id = ? AND {WITH_JID} AND type IN ({types_sql})
AND encrypted = FALSE AND retracted = FALSE AND body IS NOT NULL AND {NOT_REPLACED} ORDER BY published DESC" AND encrypted = FALSE AND retracted = FALSE AND body IS NOT NULL AND {NOT_REPLACED} ORDER BY published DESC"
)); ));
let rows: Vec<(i64, String)> = on!(db, p => sqlx::query_as(sql).bind(user).bind(jid).bind(jid).fetch_all(p).await?); let rows: Vec<(i64, String)> = on!(db, p => sqlx::query_as(sql).bind(user).bind(jid).bind(jid).bind(jid).fetch_all(p).await?);
let mut mids: Vec<i64> = rows let mut mids: Vec<i64> = rows
.into_iter() .into_iter()
.filter(|(_, body)| { .filter(|(_, body)| {
@@ -1325,11 +1330,11 @@ pub async fn messages_search(
let placeholders = vec!["?"; mids.len()].join(","); let placeholders = vec!["?"; mids.len()].join(",");
let sql = db.sql(&format!( let sql = db.sql(&format!(
"SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) AND type IN ({types_sql}) "SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND {WITH_JID} AND type IN ({types_sql})
AND retracted = FALSE AND mid IN ({placeholders}) ORDER BY published DESC LIMIT ?" AND retracted = FALSE AND mid IN ({placeholders}) ORDER BY published DESC LIMIT ?"
)); ));
Ok(on!(db, p => { Ok(on!(db, p => {
let mut q = sqlx::query_as::<_, Message>(sql).bind(user).bind(jid).bind(jid); let mut q = sqlx::query_as::<_, Message>(sql).bind(user).bind(jid).bind(jid).bind(jid);
for mid in &mids { for mid in &mids {
q = q.bind(mid); q = q.bind(mid);
} }
@@ -1349,10 +1354,10 @@ const NOT_REPLACED: &str = "NOT EXISTS (SELECT 1 FROM messages r WHERE r.user_id
pub async fn encrypted_index(db: &Db, user: &str, jid: &str, muc: bool) -> Result<Vec<(i64, String, Option<String>, String)>> { 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); let types_sql = conversation_types(muc);
let sql = db.sql(&format!( let sql = db.sql(&format!(
"SELECT mid, id, messageid, published FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) "SELECT mid, id, messageid, published FROM messages WHERE user_id = ? AND {WITH_JID}
AND type IN ({types_sql}) AND encrypted = TRUE AND retracted = FALSE AND {NOT_REPLACED} ORDER BY published DESC LIMIT 5000" AND type IN ({types_sql}) AND encrypted = TRUE AND retracted = FALSE AND {NOT_REPLACED} ORDER BY published DESC LIMIT 5000"
)); ));
Ok(on!(db, p => sqlx::query_as(sql).bind(user).bind(jid).bind(jid).fetch_all(p).await?)) Ok(on!(db, p => sqlx::query_as(sql).bind(user).bind(jid).bind(jid).bind(jid).fetch_all(p).await?))
} }
#[derive(Debug, Clone, FromRow, Serialize)] #[derive(Debug, Clone, FromRow, Serialize)]
@@ -1372,10 +1377,10 @@ pub async fn conversation_files(db: &Db, user: &str, jid: &str, muc: bool, limit
let types_sql = conversation_types(muc); let types_sql = conversation_types(muc);
let sql = db.sql(&format!( let sql = db.sql(&format!(
"SELECT m.mid, f.name, f.type, f.size, f.url, m.published FROM message_files f JOIN messages m ON m.mid = f.message_mid "SELECT m.mid, f.name, f.type, f.size, f.url, m.published FROM message_files f JOIN messages m ON m.mid = f.message_mid
WHERE m.user_id = ? AND (m.jidfrom = ? OR m.jidto = ?) AND m.type IN ({types_sql}) AND m.retracted = FALSE WHERE m.user_id = ? AND (m.jidfrom = ? OR m.jidto = ?) AND (m.jidfrom = m.jidto OR ? != m.user_id) AND m.type IN ({types_sql}) AND m.retracted = FALSE
ORDER BY m.published DESC LIMIT ?" ORDER BY m.published DESC LIMIT ?"
)); ));
Ok(on!(db, p => sqlx::query_as(sql).bind(user).bind(jid).bind(jid).bind(limit).fetch_all(p).await?)) Ok(on!(db, p => sqlx::query_as(sql).bind(user).bind(jid).bind(jid).bind(jid).bind(limit).fetch_all(p).await?))
} }
/// Forget everything stored for an account /// Forget everything stored for an account
@@ -1488,6 +1493,50 @@ pub mod tests {
db.close().await; db.close().await;
} }
#[tokio::test]
async fn notes_to_self_sqlite() {
let (db, path) = sqlite_test_db("notes").await;
notes_to_self(&db).await;
db.close().await;
remove_sqlite(&path);
}
#[tokio::test]
async fn notes_to_self_postgres() {
let Some(url) = postgres_test_url("notes").await else { return };
let db = connect(&url).await.unwrap();
notes_to_self(&db).await;
db.close().await;
}
/// The conversation with our own JID only holds the notes, not every message
async fn notes_to_self(db: &Db) {
let (me, bob) = ("me@example.org", "bob@example.org");
let m = Message {
user_id: me.into(),
id: "n1".into(),
jidfrom: bob.into(),
jidto: me.into(),
type_: "chat".into(),
body: Some("hi".into()),
published: "2026-01-01 10:00:00".into(),
..Default::default()
};
message_save(db, &m).await.unwrap();
message_save(db, &Message { id: "n2".into(), jidfrom: me.into(), jidto: bob.into(), ..m.clone() }).await.unwrap();
message_save(db, &Message { id: "n3".into(), jidfrom: me.into(), jidto: me.into(), body: Some("note".into()), ..m.clone() })
.await
.unwrap();
assert_eq!(messages_count(db, me, bob).await.unwrap(), 2);
let notes = messages_for(db, me, me, false, None, None, 10).await.unwrap();
assert_eq!(notes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(), ["n3"]);
messages_clear(db, me, me, me).await.unwrap();
assert_eq!(messages_count(db, me, me).await.unwrap(), 0);
assert_eq!(messages_count(db, me, bob).await.unwrap(), 2);
}
#[test] #[test]
fn postgres_placeholders() { fn postgres_placeholders() {
assert_eq!(to_postgres("SELECT a FROM t WHERE b = ? AND c LIKE 'x?%' AND d IN (?, ?)"), "SELECT a FROM t WHERE b = $1 AND c LIKE 'x?%' AND d IN ($2, $3)"); assert_eq!(to_postgres("SELECT a FROM t WHERE b = ? AND c LIKE 'x?%' AND d IN (?, ?)"), "SELECT a FROM t WHERE b = $1 AND c LIKE 'x?%' AND d IN ($2, $3)");
+1 -2
View File
@@ -230,8 +230,7 @@ async fn prepare_empty(ctx: &Ctx) -> String {
/// Open a 1:1 discussion (or the empty view) /// Open a 1:1 discussion (or the empty view)
pub async fn get(ctx: &Ctx, jid: Option<&str>, light: bool) { pub async fn get(ctx: &Ctx, jid: Option<&str>, light: bool) {
let me = ctx.jid(); match jid.filter(|j| !j.is_empty()) {
match jid.filter(|j| !j.is_empty() && *j != me) {
None => { None => {
rpc!(ctx, "MovimTpl.hidePanel"); rpc!(ctx, "MovimTpl.hidePanel");
rpc!(ctx, "Notif.current", "chat"); rpc!(ctx, "Notif.current", "chat");
+2 -2
View File
@@ -73,7 +73,7 @@ pub async fn prepare_chats(ctx: &Ctx) -> String {
let chats = db::open_chats(ctx.db(), &me).await.unwrap_or_default(); let chats = db::open_chats(ctx.db(), &me).await.unwrap_or_default();
let mut html = String::new(); let mut html = String::new();
for jid in chats.iter().rev() { for jid in chats.iter().rev() {
if utils::validate_jid(jid) && *jid != me { if utils::validate_jid(jid) {
html.push_str(&prepare_chat(ctx, jid).await); html.push_str(&prepare_chat(ctx, jid).await);
} }
} }
@@ -168,7 +168,7 @@ pub async fn refresh_item(ctx: &Ctx, jid: &str) {
pub async fn open(ctx: &Ctx, jid: &str, and_show: bool, history: bool) { pub async fn open(ctx: &Ctx, jid: &str, and_show: bool, history: bool) {
let me = ctx.jid(); let me = ctx.jid();
if !utils::validate_jid(jid) || jid == me { if !utils::validate_jid(jid) {
return; return;
} }
if history { if history {
+1 -2
View File
@@ -23,8 +23,7 @@ pub async fn call(ctx: &Ctx, widget: &str, func: &str, p: &P<'_>) -> bool {
("Search", "ajaxSearch") => { ("Search", "ajaxSearch") => {
let key = p.str(0).replace("xmpp:", "").trim().to_string(); let key = p.str(0).replace("xmpp:", "").trim().to_string();
let key = key.split('?').next().unwrap_or("").to_string(); let key = key.split('?').next().unwrap_or("").to_string();
let me = ctx.jid(); let html = if utils::validate_bare_jid(&key) && key.contains('@') {
let html = if utils::validate_bare_jid(&key) && key.contains('@') && key != me {
let picture = ctx.placeholder_url(&key); let picture = ctx.placeholder_url(&key);
ctx.render("Search/_search_results.html", json!({ "jid": key, "picture": picture })) ctx.render("Search/_search_results.html", json!({ "jid": key, "picture": picture }))
} else { } else {