Forwarding, in both directions
A forwarded message arrived here as an arrow with nothing beside it - the arrow a reply gets, because every reference was read as a reply and a forward resolves no referenced message. So the one sign that a forward had happened said the wrong thing. The row stays and gets its own word. "Forwarded", with its own icon, and then "Forwarded from X in #somewhere" a moment later: whose message it was is not in what Discord sends - the snapshot carries the content, the pictures and the timestamp and no author, deliberately, since a forward is the message rather than the person - so the original is read where this account can see it. Where it cannot, the row says "Forwarded" and claims nothing more. And there was no way to send one. Discord's own forward is a message whose whole content is a reference to another, which is how it carries pictures this client never re-uploads; that is what moho sends now, between conversations on the same account. Anywhere else a forward is a copy, so it is quoted and attributed rather than passed off as one's own words - and the picker says which of the two it will be before it is sent. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+100
-40
@@ -2794,6 +2794,7 @@ mod forwarded_tests {
|
||||
fn forward(content: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"content": content,
|
||||
"message_reference": { "type": 1, "channel_id": "1", "message_id": "2" },
|
||||
"message_snapshots": [{
|
||||
"message": {
|
||||
"content": "the thing that was forwarded",
|
||||
@@ -2805,18 +2806,26 @@ mod forwarded_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forward_says_that_it_is_one() {
|
||||
// The bug: this arrived as an author with nothing under them. And
|
||||
// then: as a quote that read like somebody quoting, rather than as a
|
||||
// message brought here from somewhere else.
|
||||
fn a_forward_carries_the_message_it_brought() {
|
||||
// The bug: this arrived as an author with nothing under them.
|
||||
let body = extract_body(&forward("")).expect("a forward is not an empty message");
|
||||
assert_eq!(body, "Forwarded\n> the thing that was forwarded");
|
||||
assert_eq!(body, "> the thing that was forwarded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_above_a_forward_keeps_its_place() {
|
||||
let body = extract_body(&forward("look at this")).unwrap();
|
||||
assert_eq!(body, "look at this\nForwarded\n> the thing that was forwarded");
|
||||
assert_eq!(body, "look at this\n> the thing that was forwarded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forward_says_that_it_is_one() {
|
||||
// Said in the row above the message rather than in the body, which is
|
||||
// where a reply's own line goes - and with its own word, because a
|
||||
// forward is not an answer to anything.
|
||||
let preview = extract_reply(&forward("")).expect("a forward names what it brought");
|
||||
assert!(preview.forwarded);
|
||||
assert_eq!(preview.from, "Forwarded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2840,13 +2849,13 @@ mod forwarded_tests {
|
||||
assert_eq!(atts[0].kind, "image");
|
||||
// The message is not left blank either, since a client that draws no
|
||||
// attachment would otherwise show nothing at all.
|
||||
assert_eq!(extract_body(&d).as_deref(), Some("Forwarded\n> (forwarded 1 attachment)"));
|
||||
assert_eq!(extract_body(&d).as_deref(), Some("> (forwarded 1 attachment)"));
|
||||
// And the message's own attachments are untouched by this.
|
||||
assert!(extract_attachments(&d).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forward_is_not_a_reply() {
|
||||
fn a_forward_is_not_drawn_as_a_reply() {
|
||||
// What produced the arrow: a forward has a message_reference, so it
|
||||
// was read as a reply to a message Discord never resolves - drawn as
|
||||
// "replying to" with no author and no text beside it.
|
||||
@@ -2855,7 +2864,9 @@ mod forwarded_tests {
|
||||
"message_reference": { "type": 1, "channel_id": "1", "message_id": "2" },
|
||||
"message_snapshots": [{ "message": { "content": "brought from elsewhere", "attachments": [] } }]
|
||||
});
|
||||
assert!(extract_reply(&d).is_none());
|
||||
let forwarded = extract_reply(&d).expect("a forward still names what it brought");
|
||||
assert!(forwarded.forwarded);
|
||||
assert!(forwarded.body.is_empty());
|
||||
// An actual reply still is one.
|
||||
let reply = json!({
|
||||
"content": "yes",
|
||||
@@ -3019,13 +3030,10 @@ fn forwarded_text(d: &Value) -> Vec<String> {
|
||||
.iter()
|
||||
.filter_map(|snapshot| {
|
||||
let message = &snapshot["message"];
|
||||
// Said outright, because a quoted line on its own reads as
|
||||
// somebody quoting rather than as a message brought here from
|
||||
// somewhere else - and the difference is the whole of what a
|
||||
// forward is. Who wrote it is not in the snapshot: Discord sends
|
||||
// the message without an author, deliberately, so the name is
|
||||
// filled in afterwards where it can be read - see name_forward.
|
||||
let mut parts: Vec<String> = vec![FORWARD_MARK.to_string()];
|
||||
// Just the message. That it is a forward, and whose it was, is
|
||||
// said in the row above it - see extract_reply and name_forward -
|
||||
// rather than as a line of body text pretending to be one.
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(text) = message["content"].as_str().filter(|t| !t.is_empty()) {
|
||||
// Quoted, because that is what it is: something said
|
||||
// elsewhere, brought here.
|
||||
@@ -3035,14 +3043,10 @@ fn forwarded_text(d: &Value) -> Vec<String> {
|
||||
// message's own attachments below; this is for the case where a
|
||||
// forward is *only* a picture, so the line is not empty.
|
||||
let files = message["attachments"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||
// Only where the forward has no words: the mark is already there,
|
||||
// so "nothing but the mark" is what an empty one looks like.
|
||||
if parts.len() == 1 && files > 0 {
|
||||
if parts.is_empty() && files > 0 {
|
||||
parts.push(format!("> (forwarded {files} attachment{})", if files == 1 { "" } else { "s" }));
|
||||
}
|
||||
// The mark alone is not a message; something has to have been
|
||||
// forwarded for this to be one.
|
||||
if parts.len() < 2 {
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(parts.join("\n"))
|
||||
@@ -3062,7 +3066,7 @@ pub const FORWARD_MARK: &str = "Forwarded";
|
||||
///
|
||||
/// Done afterwards rather than before, so a forward appears at once and gains
|
||||
/// its attribution a moment later, the same way a late link preview does.
|
||||
pub async fn name_forward(state: &AppState, account_id: &str, buffer_id: &str, msg_id: &str, reference: &Value, body: &str) {
|
||||
pub async fn name_forward(state: &AppState, account_id: &str, buffer_id: &str, msg_id: &str, reference: &Value) {
|
||||
let (Some(source_channel), Some(source_message)) =
|
||||
(reference["channel_id"].as_str(), reference["message_id"].as_str())
|
||||
else {
|
||||
@@ -3110,11 +3114,7 @@ pub async fn name_forward(state: &AppState, account_id: &str, buffer_id: &str, m
|
||||
None if original["guild_id"].is_null() => " from a direct message".to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
let named = format!("{FORWARD_MARK} from {name}{place}");
|
||||
let next = body.replacen(FORWARD_MARK, &named, 1);
|
||||
if next != body {
|
||||
state.runtime.update_message_body_only(state, buffer_id, msg_id, &next);
|
||||
}
|
||||
state.runtime.rename_reply_preview(state, buffer_id, msg_id, &format!("{FORWARD_MARK} from {name}{place}"));
|
||||
}
|
||||
|
||||
/// The files inside a forwarded message, as attachments of this one.
|
||||
@@ -3246,14 +3246,24 @@ fn mentions_own_user(d: &Value, own_user_id: &str) -> bool {
|
||||
/// gets deleted. `id` is what the frontend's "jump to" click targets if
|
||||
/// the original happens to already be loaded.
|
||||
fn extract_reply(d: &Value) -> Option<ReplyPreview> {
|
||||
// A forward carries a reference too - to the message it brought here -
|
||||
// and it is not a reply to it. Read as one, it drew the arrow a reply
|
||||
// gets with nothing beside it, since a forward resolves no referenced
|
||||
// message: type 1 is Discord's own word for the difference.
|
||||
if d["message_reference"]["type"].as_i64() == Some(1) || d["message_snapshots"].is_array() {
|
||||
return None;
|
||||
}
|
||||
let reply_id = d["message_reference"]["message_id"].as_str()?;
|
||||
// A forward carries a reference too - to the message it brought here -
|
||||
// and it is not a reply to it. Same row on screen, different word in it:
|
||||
// read as a reply it drew the arrow a reply gets and nothing beside it,
|
||||
// since a forward resolves no referenced message. Type 1 is Discord's own
|
||||
// word for the difference.
|
||||
if d["message_reference"]["type"].as_i64() == Some(1) || d["message_snapshots"].is_array() {
|
||||
return Some(ReplyPreview {
|
||||
id: reply_id.to_string(),
|
||||
// Whose message it was is not in what Discord sent - the snapshot
|
||||
// has no author - so it is read afterwards and filled in here by
|
||||
// name_forward.
|
||||
from: FORWARD_MARK.to_string(),
|
||||
body: String::new(),
|
||||
thread: false,
|
||||
forwarded: true,
|
||||
});
|
||||
}
|
||||
let referenced = &d["referenced_message"];
|
||||
if referenced.is_null() {
|
||||
// Reference exists but Discord didn't resolve it - still worth a
|
||||
@@ -3263,7 +3273,7 @@ fn extract_reply(d: &Value) -> Option<ReplyPreview> {
|
||||
let author = &referenced["author"];
|
||||
let from = author["global_name"].as_str().filter(|s| !s.is_empty()).or_else(|| author["username"].as_str()).unwrap_or("unknown").to_string();
|
||||
let body = extract_body(referenced).unwrap_or_default();
|
||||
Some(ReplyPreview { id: reply_id.to_string(), from, body, thread: false })
|
||||
Some(ReplyPreview { id: reply_id.to_string(), from, body, thread: false, forwarded: false })
|
||||
}
|
||||
|
||||
/// Shared by both the initial backfill and extend_history - Discord
|
||||
@@ -3309,9 +3319,8 @@ fn store_history_messages(state: &AppState, buffer_id: &str, messages: &[Value],
|
||||
let buffer = buffer_id.to_string();
|
||||
let msg_id = msg_id.to_string();
|
||||
let reference = msg["message_reference"].clone();
|
||||
let body = body.clone();
|
||||
tokio::spawn(async move {
|
||||
name_forward(&state, &account_id, &buffer, &msg_id, &reference, &body).await;
|
||||
name_forward(&state, &account_id, &buffer, &msg_id, &reference).await;
|
||||
});
|
||||
}
|
||||
// Backfilled messages need previews as much as live ones do - more so,
|
||||
@@ -5071,9 +5080,8 @@ async fn run_gateway(state: &AppState, config: &DiscordAccountConfig, session: &
|
||||
let account_id = account_id.clone();
|
||||
let buffer_id = model::buffer_id(&account_id, &buffer_name);
|
||||
let reference = d["message_reference"].clone();
|
||||
let body = body.clone();
|
||||
tokio::spawn(async move {
|
||||
name_forward(&state, &account_id, &buffer_id, &msg_id, &reference, &body).await;
|
||||
name_forward(&state, &account_id, &buffer_id, &msg_id, &reference).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5595,6 +5603,58 @@ fn mention_candidates(state: &AppState, account_id: &str, buffer_id: &str) -> Ve
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Sends somebody else's message on, as a forward.
|
||||
///
|
||||
/// Not a copy with quotation marks round it: Discord has a forward of its own,
|
||||
/// and it is a message whose entire content is a reference to another one. The
|
||||
/// server takes the snapshot, so what arrives at the far end is the original -
|
||||
/// which is how a forward carries pictures and embeds this client never
|
||||
/// re-uploads.
|
||||
///
|
||||
/// The source channel is named as well as the message, because a forward
|
||||
/// crosses rooms and servers, and that is the whole point of it.
|
||||
pub async fn forward_message(
|
||||
state: &AppState,
|
||||
from_buffer: &str,
|
||||
message_id: &str,
|
||||
to_buffer: &str,
|
||||
token: &str,
|
||||
) -> Result<()> {
|
||||
let source_channel = state
|
||||
.runtime
|
||||
.get_discord_channel(from_buffer)
|
||||
.ok_or_else(|| anyhow!("that message is not in a Discord conversation"))?;
|
||||
let target_channel = state
|
||||
.runtime
|
||||
.get_discord_channel(to_buffer)
|
||||
.ok_or_else(|| anyhow!("there is no Discord conversation to send it to"))?;
|
||||
let mut reference = json!({
|
||||
// 1 is Discord's own number for a forward, as against 0 for a reply.
|
||||
"type": 1,
|
||||
"channel_id": source_channel,
|
||||
"message_id": message_id,
|
||||
});
|
||||
// Where it is from, when it is from a server. A direct message has no
|
||||
// guild, and naming one would be a claim the server would refuse.
|
||||
if let Some(guild_id) = state.runtime.get_discord_guild(from_buffer) {
|
||||
reference["guild_id"] = json!(guild_id);
|
||||
}
|
||||
let resp = send_write(
|
||||
http_client()
|
||||
.post(format!("{API_BASE}/channels/{target_channel}/messages"))
|
||||
.header("Authorization", token)
|
||||
.json(&json!({ "content": "", "message_reference": reference })),
|
||||
)
|
||||
.await
|
||||
.context("forwarding the message")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("{}", discord_error_text(status, &text, "forwarding that message"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_message(state: &AppState, buffer_id: &str, token: &str, body: &str, reply_to_id: Option<&str>) -> Result<()> {
|
||||
let channel_id = state
|
||||
.runtime
|
||||
|
||||
@@ -1702,8 +1702,7 @@ fn reply_preview(metadata: Option<&ReplyMetadata>) -> Option<crate::model::Reply
|
||||
id: original.id.clone(),
|
||||
from,
|
||||
body: original.content.clone().unwrap_or_default(),
|
||||
thread: false,
|
||||
})
|
||||
thread: false, forwarded: false })
|
||||
}
|
||||
|
||||
/// One line for something somebody did in a channel, or None if this event is
|
||||
|
||||
@@ -694,7 +694,7 @@ fn relation_preview(state: &AppState, buffer_id: &str, content: &Value) -> Optio
|
||||
let in_thread = thread.is_some();
|
||||
let target = thread.or_else(|| protocol::reply_target(content))?;
|
||||
match state.store.get_message(buffer_id, target) {
|
||||
Ok(Some(m)) => Some(crate::model::ReplyPreview { id: target.to_string(), from: m.from, body: m.body, thread: in_thread }),
|
||||
Ok(Some(m)) => Some(crate::model::ReplyPreview { id: target.to_string(), from: m.from, body: m.body, thread: in_thread, forwarded: false }),
|
||||
_ => Some(crate::model::ReplyPreview { id: target.to_string(), thread: in_thread, ..Default::default() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,15 @@ pub struct ReplyPreview {
|
||||
/// is somewhere, and only the second can be opened and continued.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub thread: bool,
|
||||
/// Whether this names a message brought here rather than one answered.
|
||||
///
|
||||
/// Discord says a reply and a forward the same way - a reference to
|
||||
/// another message - and only the reference's own type tells them apart.
|
||||
/// Read as a reply, a forward drew the arrow a reply gets with nothing
|
||||
/// beside it, which is how a forwarded message announced itself as
|
||||
/// something else entirely.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub forwarded: bool,
|
||||
}
|
||||
|
||||
/// How a service says a sender should look.
|
||||
|
||||
@@ -3759,6 +3759,83 @@ pub async fn dispatch(
|
||||
(Some(serde_json::json!(live)), None)
|
||||
}
|
||||
|
||||
// Sending somebody else's message on, into another conversation.
|
||||
"forwardMessage" => {
|
||||
let (from_buffer, message_id, to_buffer) = match (
|
||||
p_str_opt(params, "fromBufferId"),
|
||||
p_str_opt(params, "messageId"),
|
||||
p_str_opt(params, "toBufferId"),
|
||||
) {
|
||||
(Some(f), Some(m), Some(t)) => (f, m, t),
|
||||
_ => {
|
||||
return (
|
||||
None,
|
||||
Some("forwardMessage requires \"fromBufferId\", \"messageId\" and \"toBufferId\"".to_string()),
|
||||
)
|
||||
}
|
||||
};
|
||||
let (Some(source), Some(target)) = (state.runtime.get_buffer(from_buffer), state.runtime.get_buffer(to_buffer)) else {
|
||||
return (None, Some("no such conversation".to_string()));
|
||||
};
|
||||
// Discord forwards natively; everywhere else a forward is a copy,
|
||||
// and a copy of somebody's words sent as your own is a different
|
||||
// thing - so it is quoted and attributed rather than passed off.
|
||||
if source.account_id.starts_with("discord:") && target.account_id == source.account_id {
|
||||
let Some(cfg) = state.accounts.get_discord(&source.account_id) else {
|
||||
return (None, Some("account not connected".to_string()));
|
||||
};
|
||||
return match backend::discord::forward_message(state, from_buffer, message_id, to_buffer, &cfg.token).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
};
|
||||
}
|
||||
// The copy: what was said, who said it, quoted so it reads as
|
||||
// theirs.
|
||||
let Some(original) = state.store.get_message(from_buffer, message_id).ok().flatten() else {
|
||||
return (None, Some("that message is no longer here to send on".to_string()));
|
||||
};
|
||||
let quoted: String = original
|
||||
.body
|
||||
.lines()
|
||||
.map(|line| format!("> {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let body = format!("Forwarded from {}:\n{quoted}", original.from);
|
||||
// Sent the way any other message is, per service. Not by calling
|
||||
// back into this dispatcher: an async function that calls itself
|
||||
// has to be boxed, and one branch of one method is not worth
|
||||
// boxing every call in the daemon.
|
||||
match crate::model::service_of(&target.account_id) {
|
||||
"matrix" => match state.accounts.get_matrix(&target.account_id) {
|
||||
None => (None, Some("account not connected".to_string())),
|
||||
Some(cfg) => match backend::matrix::send_message(state, &target.account_id, to_buffer, &cfg.access_token, &body, None, false, None).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
},
|
||||
},
|
||||
"discord" => match state.accounts.get_discord(&target.account_id) {
|
||||
None => (None, Some("account not connected".to_string())),
|
||||
Some(cfg) => match backend::discord::send_message(state, to_buffer, &cfg.token, &body, None).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
},
|
||||
},
|
||||
"irc" => match state.runtime.irc_sender(&target.account_id) {
|
||||
None => (None, Some("account not connected".to_string())),
|
||||
Some(sender) => match backend::irc::send_message(state, &target.account_id, &sender, &target.name, &body) {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e.to_string())),
|
||||
},
|
||||
},
|
||||
// Kick and Sneedchat both send a line of text and nothing
|
||||
// else, which is what this is.
|
||||
_ => (
|
||||
None,
|
||||
Some("forwarding into this conversation is not supported yet".to_string()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// A form a bot asked for, filled in and sent back.
|
||||
"submitDiscordModal" => {
|
||||
let (buffer_id, custom_id) = match (p_str_opt(params, "bufferId"), p_str_opt(params, "customId")) {
|
||||
|
||||
@@ -3459,6 +3459,19 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Says who wrote the message a forward brought here, once that has been
|
||||
/// looked up - the row above it says "Forwarded" until then.
|
||||
pub fn rename_reply_preview(&self, state: &AppState, buffer_id: &str, msg_id: &str, from: &str) {
|
||||
match state.store.rename_reply_from(buffer_id, msg_id, from) {
|
||||
Ok(true) => state.events.emit(
|
||||
"messageReplyRenamed",
|
||||
json!({ "bufferId": buffer_id, "id": msg_id, "from": from }),
|
||||
),
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!("failed to name a forwarded message: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Discord's MESSAGE_DELETE - removes the row and tells clients to
|
||||
/// drop it from view, same as Discord's own clients (no tombstone).
|
||||
pub fn delete_message(&self, state: &AppState, buffer_id: &str, msg_id: &str) {
|
||||
|
||||
+27
-10
@@ -134,6 +134,9 @@ impl Store {
|
||||
// same way; what differs is that a thread can be opened and
|
||||
// continued, and a reply target cannot.
|
||||
"ALTER TABLE messages ADD COLUMN reply_is_thread INTEGER NOT NULL DEFAULT 0",
|
||||
// Whether that reference is a message brought here rather than
|
||||
// one answered - see model::ReplyPreview::forwarded.
|
||||
"ALTER TABLE messages ADD COLUMN reply_forwarded INTEGER NOT NULL DEFAULT 0",
|
||||
] {
|
||||
let _ = conn.execute(stmt, []);
|
||||
}
|
||||
@@ -214,8 +217,8 @@ impl Store {
|
||||
// OR IGNORE against the (buffer_id, msg_id) index: recording the
|
||||
// same message twice is a backend replaying history it already
|
||||
// has, and the right answer is to keep the copy already stored.
|
||||
"INSERT OR IGNORE INTO messages (msg_id, buffer_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, buffer_kind, sender_color, badges, reply_is_thread)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, COALESCE(?12, '[]'), ?13, ?14, COALESCE(?15, '[]'), ?16, COALESCE(?17, '[]'), ?18, ?19, ?20, COALESCE(?21, '[]'), ?22)",
|
||||
"INSERT OR IGNORE INTO messages (msg_id, buffer_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, buffer_kind, sender_color, badges, reply_is_thread, reply_forwarded)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, COALESCE(?12, '[]'), ?13, ?14, COALESCE(?15, '[]'), ?16, COALESCE(?17, '[]'), ?18, ?19, ?20, COALESCE(?21, '[]'), ?22, ?23)",
|
||||
params![
|
||||
msg_id,
|
||||
buffer_id,
|
||||
@@ -239,6 +242,7 @@ impl Store {
|
||||
sender_color,
|
||||
serde_json::to_string(badges).ok(),
|
||||
reply_to.is_some_and(|r| r.thread) as i32,
|
||||
reply_to.is_some_and(|r| r.forwarded) as i32,
|
||||
],
|
||||
)?;
|
||||
// Whether this was actually new. The insert has always ignored a
|
||||
@@ -269,6 +273,19 @@ impl Store {
|
||||
/// attachment-link resolution swapping in a Tor-fetched local copy
|
||||
/// once ready) that aren't a real user edit and shouldn't show an
|
||||
/// "(edited)" label.
|
||||
/// Renames the thing a message points at, without touching the message.
|
||||
///
|
||||
/// For a forward, whose author is not in what the service sent and has to
|
||||
/// be read afterwards - see backend/discord's name_forward.
|
||||
pub fn rename_reply_from(&self, buffer_id: &str, msg_id: &str, from: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let changed = conn.execute(
|
||||
"UPDATE messages SET reply_to_from = ?1 WHERE buffer_id = ?2 AND msg_id = ?3",
|
||||
params![from, buffer_id, msg_id],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn update_message_body_silent(&self, buffer_id: &str, msg_id: &str, body: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let rows = conn.execute("UPDATE messages SET body = ?1 WHERE buffer_id = ?2 AND msg_id = ?3", params![body, buffer_id, msg_id])?;
|
||||
@@ -437,7 +454,7 @@ impl Store {
|
||||
from: row.get::<_, Option<String>>("reply_to_from").ok().flatten().unwrap_or_default(),
|
||||
body: row.get::<_, Option<String>>("reply_to_body").ok().flatten().unwrap_or_default(),
|
||||
thread: row.get::<_, Option<i64>>("reply_is_thread").ok().flatten().unwrap_or(0) != 0,
|
||||
});
|
||||
forwarded: row.get::<_, Option<i64>>("reply_forwarded").ok().flatten().unwrap_or(0) != 0 });
|
||||
let json_column = |name: &str| -> String {
|
||||
row.get::<_, Option<String>>(name).ok().flatten().unwrap_or_else(|| "[]".to_string())
|
||||
};
|
||||
@@ -475,7 +492,7 @@ impl Store {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = if limit > 0 { limit } else { 200 };
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND (?2 <= 0 OR ts < ?2)
|
||||
ORDER BY ts DESC LIMIT ?3",
|
||||
@@ -551,7 +568,7 @@ impl Store {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = if limit > 0 { limit } else { 200 };
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND ts > ?2
|
||||
ORDER BY ts ASC LIMIT ?3",
|
||||
@@ -570,7 +587,7 @@ impl Store {
|
||||
pub fn messages_around(&self, buffer_id: &str, ts: i64, span: i64) -> Result<Vec<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let span = if span > 0 { span } else { 50 };
|
||||
const COLUMNS: &str = "msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components";
|
||||
const COLUMNS: &str = "msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components";
|
||||
|
||||
let mut older = conn.prepare(&format!(
|
||||
"SELECT {COLUMNS} FROM messages WHERE buffer_id = ?1 AND ts <= ?2 ORDER BY ts DESC LIMIT ?3"
|
||||
@@ -616,7 +633,7 @@ impl Store {
|
||||
format!(" OR (buffer_kind IS NULL AND buffer_id IN ({places}))")
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components, buffer_id
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, buffer_id
|
||||
FROM messages
|
||||
WHERE is_highlight = 1 AND is_own = 0 AND (buffer_kind = 'channel'{legacy})
|
||||
ORDER BY ts DESC LIMIT ?"
|
||||
@@ -753,7 +770,7 @@ impl Store {
|
||||
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||
let pattern = format!("%{escaped}%");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND body LIKE ?2 ESCAPE '\\'
|
||||
ORDER BY ts DESC LIMIT ?3",
|
||||
@@ -818,7 +835,7 @@ impl Store {
|
||||
pub fn thread_messages(&self, buffer_id: &str, root_id: &str) -> Result<Vec<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND ((reply_to_id = ?2 AND reply_is_thread = 1) OR msg_id = ?2)
|
||||
ORDER BY ts ASC, rowid ASC",
|
||||
@@ -830,7 +847,7 @@ impl Store {
|
||||
pub fn get_message(&self, buffer_id: &str, msg_id: &str) -> Result<Option<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND msg_id = ?2",
|
||||
)?;
|
||||
|
||||
Reference in New Issue
Block a user