A Matrix room can be put back to unread

Reading a room was one-way: once seen it was read, with no way to leave one
deliberately unread as a note to come back to it. Every other client in the
ecosystem has this, and because the mark is account data it follows
somebody between them - so a room flagged on a phone looked read here, and
a room read here could not be flagged at all.

Both spellings are read. `m.marked_unread` is the stable name from Matrix
1.12 and `com.famedly.marked_unread` is what clients sent for years before
it, so a room marked from an older client carries only that one. Only the
stable name is written: a client old enough to want the unstable one will
not be reading this account's rooms for much longer, and writing both would
leave two facts free to disagree.

An absent key is not the same as `false`, which is the part worth being
careful about. A room that was marked and then unmarked carries an explicit
false, and that has to beat a stale true this session already knows about -
so silence means "no opinion" and is not allowed to overwrite anything.

Closes #194.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-12 09:46:49 -04:00
co-authored by Claude Opus 5
parent e0ac756c39
commit 03e8bc84ef
3 changed files with 115 additions and 0 deletions
+74
View File
@@ -156,6 +156,80 @@ pub async fn mark_read(state: &AppState, account_id: &str, buffer_id: &str, publ
Ok(())
}
/// The two spellings of "I have not finished with this room".
///
/// `m.marked_unread` is the stable name, from Matrix 1.12. `com.famedly.marked_unread`
/// is what clients sent for the years before it, and rooms marked from an
/// older client still carry only that one - so both are read, and the stable
/// one is what gets written.
pub const MARKED_UNREAD: [&str; 2] = ["m.marked_unread", "com.famedly.marked_unread"];
#[cfg(test)]
mod marked_unread_tests {
use super::*;
fn ev(kind: &str, unread: bool) -> Value {
serde_json::json!({ "type": kind, "content": { "unread": unread } })
}
#[test]
fn reads_either_spelling() {
assert_eq!(marked_unread_in(&[ev("m.marked_unread", true)]), Some(true));
assert_eq!(marked_unread_in(&[ev("com.famedly.marked_unread", true)]), Some(true));
}
/// A room that was marked and then unmarked carries an explicit false, and
/// that has to win over a stale true this client saw earlier - so it is a
/// value, not an absence.
#[test]
fn an_explicit_false_is_not_the_same_as_silence() {
assert_eq!(marked_unread_in(&[ev("m.marked_unread", false)]), Some(false));
assert_eq!(marked_unread_in(&[]), None);
assert_eq!(marked_unread_in(&[serde_json::json!({ "type": "m.tag", "content": {} })]), None);
}
/// Content that says nothing usable is silence rather than a guess.
#[test]
fn a_mark_with_no_answer_in_it_says_nothing() {
let odd = serde_json::json!({ "type": "m.marked_unread", "content": { "unread": "yes" } });
assert_eq!(marked_unread_in(&[odd]), None);
}
}
/// Reads a room's own account data for a deliberate unread mark.
///
/// `None` when the room says nothing either way, which is not the same as
/// `Some(false)`: a room that was marked and then unmarked carries an explicit
/// false, and that has to win over a stale true this client saw earlier.
pub fn marked_unread_in(events: &[Value]) -> Option<bool> {
events
.iter()
.find(|e| e["type"].as_str().is_some_and(|t| MARKED_UNREAD.contains(&t)))
.and_then(|e| e["content"]["unread"].as_bool())
}
/// Says this room still wants reading, or stops saying it.
///
/// Account data rather than anything local, so the mark travels: a room
/// flagged here is flagged on the phone, which is the whole point of marking
/// one. Written under the stable name only - a client old enough to want the
/// unstable one will not be reading this account's rooms for much longer, and
/// writing both would leave two facts to disagree.
pub async fn set_marked_unread(state: &AppState, account_id: &str, buffer_id: &str, unread: bool) -> Result<()> {
let Some(config) = state.accounts.get_matrix(account_id) else { anyhow::bail!("no such account") };
let Some(room_id) = state.runtime.get_matrix_room(buffer_id) else {
anyhow::bail!("no known Matrix room for this buffer")
};
let base = config.homeserver_url.trim_end_matches('/');
let user = url::form_urlencoded::byte_serialize(config.user_id.as_bytes()).collect::<String>();
let room = url::form_urlencoded::byte_serialize(room_id.as_bytes()).collect::<String>();
let url = format!("{base}/_matrix/client/v3/user/{user}/rooms/{room}/account_data/m.marked_unread");
http::put_json(&url, &config.access_token, serde_json::json!({ "unread": unread }))
.await
.context("setting the unread mark")?;
Ok(())
}
pub async fn send_typing(state: &AppState, account_id: &str, room_id: &str, typing: bool) -> Result<()> {
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
let base = account.homeserver_url.trim_end_matches('/');
+23
View File
@@ -540,6 +540,29 @@ pub(super) async fn process_sync_response(state: &AppState, account_id: &str, ow
}
}
// Somebody deliberately left this room unread, here or on another
// client. Read every sync because it changes from elsewhere, and a
// mark made on a phone that never reached this window would be a
// reminder that only exists where it was set.
//
// Only emitted when the room actually says something: an absent key
// means "no opinion", which must not overwrite a mark this session
// already knows about.
if let Some(events) = room["account_data"]["events"].as_array() {
if let Some(unread) = receipts::marked_unread_in(events) {
if let Some((buffer_name, _)) = state.runtime.get_matrix_room_name(account_id, room_id) {
state.events.emit(
"markedUnread",
serde_json::json!({
"accountId": account_id,
"bufferId": crate::model::buffer_id(account_id, &buffer_name),
"unread": unread,
}),
);
}
}
}
// The room has been heard from, so it is no longer waiting to be. Set
// on join and cleared here, which is the first moment there is
// anything true to say about what is in it.
+18
View File
@@ -818,6 +818,24 @@ pub async fn dispatch(
(Some(serde_json::json!({ "messages": messages })), None)
}
// Deliberately leaving a room unread, which reading it is the only
// way to undo here. Matrix only: it is the one service in moho with an
// account-level place to put the mark, so it follows somebody between
// their clients rather than being a note this window keeps to itself.
"setMatrixMarkedUnread" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("setMatrixMarkedUnread requires \"bufferId\"".to_string()));
};
let unread = params.get("unread").and_then(Value::as_bool).unwrap_or(true);
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
return (None, Some("no such conversation".to_string()));
};
match backend::matrix::receipts::set_marked_unread(state, &buffer.account_id, buffer_id, unread).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
"markBufferRead" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("markBufferRead requires \"bufferId\"".to_string()));