Tell Discord what has been read

Unread was purely local, so reading a channel here did not clear it on a
phone and reading it there did not clear it here - every conversation
already dealt with elsewhere still looked unread.

Both directions now. Opening a conversation acks it, and MESSAGE_ACK
announces one read anywhere else. Discord sends that to every session an
account has, including the one that acked, so a client acting on it
settles its own state rather than having to guess.

What gets acked is the newest message this client actually holds, not the
newest that exists. Acking past something never seen would mark it read
on every device on the strength of a message this one never showed.

markBufferRead is quietly a no-op on the protocols with nowhere to put it
- IRC and Sneedchat have no read state, and Matrix receipts are their own
work - so a client can call it on every buffer it opens without first
asking what protocol it is.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-29 10:34:55 -04:00
co-authored by Claude Opus 5
parent ac1aa67fec
commit 6c7be5a1f9
2 changed files with 70 additions and 0 deletions
+49
View File
@@ -2430,6 +2430,22 @@ async fn run_gateway(state: &AppState, config: &DiscordAccountConfig) -> Result<
}
}
// Read somewhere else. Discord sends this to every one
// of an account's sessions, including the one that did
// the acking, so a client acting on it also settles its
// own state rather than needing to guess.
"MESSAGE_ACK" => {
let Some(channel_id) = d["channel_id"].as_str() else { continue };
let Some((name, _)) = channel_map.get(channel_id) else { continue };
state.events.emit(
"bufferRead",
json!({
"accountId": account_id,
"bufferId": crate::model::buffer_id(&account_id, name),
}),
);
}
"CHANNEL_DELETE" => {
let Some(channel_id) = d["id"].as_str() else { continue };
if let Some((name, _)) = channel_map.remove(channel_id) {
@@ -2771,6 +2787,39 @@ pub async fn edit_message(state: &AppState, buffer_id: &str, token: &str, msg_id
/// DELETE .../messages/{id} - deleting your own message. Same
/// author-only enforcement as edit; also works for a moderator with
/// MANAGE_MESSAGES, which Discord itself decides, not this code.
/// Tells Discord this conversation has been read up to its newest message.
///
/// Unread was purely local before this, so reading a channel here did not
/// clear it on a phone and reading it there did not clear it here - the gap
/// most visible to anyone who uses Discord on more than one device.
///
/// The newest message this client actually holds is what gets acked, not the
/// newest that exists: acking past something never seen would mark it read
/// on every device on the strength of a message this one never showed.
///
/// Quietly does nothing for a buffer with no messages, which is an ordinary
/// state for a channel opened and never scrolled.
pub async fn ack_read(state: &AppState, buffer_id: &str, token: &str) -> Result<()> {
let channel_id = state
.runtime
.get_discord_channel(buffer_id)
.ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
let Some(msg_id) = state.store.newest_msg_id(buffer_id)? else { return Ok(()) };
let resp = http_client()
.post(format!("{API_BASE}/channels/{channel_id}/messages/{msg_id}/ack"))
.header("Authorization", token)
.json(&json!({ "token": serde_json::Value::Null }))
.send()
.await
.context("acking Discord read state")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("Discord API error {status}: {text}");
}
Ok(())
}
pub async fn delete_message(state: &AppState, buffer_id: &str, token: &str, msg_id: &str) -> Result<()> {
let channel_id = state
.runtime
+21
View File
@@ -307,6 +307,27 @@ pub async fn dispatch(
}
}
// "I have read this." Only Discord has anywhere to put it - IRC and
// Sneedchat have no read state at all, and Matrix's receipts are
// their own piece of work - so this is quietly a no-op elsewhere
// rather than an error, letting a client call it on every buffer it
// opens without first asking what protocol it is.
"markBufferRead" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("markBufferRead requires \"bufferId\"".to_string()));
};
match state.runtime.get_buffer(buffer_id) {
Some(buffer) if buffer.account_id.starts_with("discord:") => match state.accounts.get_discord(&buffer.account_id) {
None => (None, Some("account not connected".to_string())),
Some(cfg) => match backend::discord::ack_read(state, buffer_id, &cfg.token).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e.to_string())),
},
},
_ => (Some(ok_node()), None),
}
}
"joinDiscordGuild" => {
let (account_id, invite) = match (p_str_opt(params, "accountId"), p_str_opt(params, "invite")) {
(Some(a), Some(i)) => (a, i),