Keep the room tags the account already set

m.tag was unimplemented - its only occurrence in the backend was a test
fixture - so Element's favourites and low-priority rooms meant nothing
here, and somebody who had spent an evening organising their rooms saw
none of it (#216).

Read at connect and followed as it changes: one filtered sync rather
than a request per room, for the same reason the receipts and invites
beside it need their own, which is that a resumed session is told only
what changed and a tag set months ago has not. setMatrixRoomTag writes
one tag at a time, which is what the endpoint offers and also the right
granularity - the list is shared with every other client, and replacing
it whole to change one entry would drop the u.* tags this client cannot
show.

The two land on the buffer as `favourite` and `lowPriority` rather than
as Matrix vocabulary: a starred conversation is not a Matrix idea, and
the rail sorts by what a conversation is rather than by which protocol
it came from.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-13 13:19:55 -04:00
co-authored by Claude Opus 5
parent 411b6617b9
commit b7d42d8601
6 changed files with 225 additions and 0 deletions
+1
View File
@@ -87,6 +87,7 @@ pub mod sliding;
pub mod dehydration;
pub mod ssss;
pub mod stickers;
pub mod tags;
pub mod rooms;
pub mod verification;
+14
View File
@@ -170,6 +170,11 @@ pub(super) async fn run_sync(state: &AppState, config: &MatrixAccountConfig, acc
tracing::debug!("matrix[{account_id}]: pending invites: {e:#}");
}
// And how the account has filed its rooms - see tags::fetch.
if let Err(e) = tags::fetch(state, account_id, &config.homeserver_url, &access_token).await {
tracing::debug!("matrix[{account_id}]: room tags: {e:#}");
}
// And what this account has asked to be told about - see fetch_push_rules.
fetch_push_rules(state, account_id, &config.homeserver_url, &access_token).await;
// And who it has asked never to hear from.
@@ -732,6 +737,15 @@ pub(super) async fn process_sync_response(state: &AppState, account_id: &str, ow
let buffer = state.runtime.ensure_buffer(state, account_id, &buffer_name, &buffer_kind);
state.runtime.set_matrix_room(state, &buffer.id, room_id);
// How the account has filed this room. Read every sync because it is
// set from any client, and after the buffer exists because it is the
// buffer this moves in the rail.
for event in room["account_data"]["events"].as_array().into_iter().flatten() {
if event["type"].as_str() == Some("m.tag") {
tags::apply(state, account_id, room_id, &event["content"]);
}
}
// A room can be added to a space at any time, and a space seen after
// this room was first synced only records the mapping - so this is
// checked every sync rather than only when the buffer is created.
+146
View File
@@ -0,0 +1,146 @@
//! What the account has filed a room under.
//!
//! `m.tag` is per-room account data, and it is how Element sorts a room list:
//! `m.favourite` to the top, `m.lowpriority` to the bottom. Both travel with
//! the account, so somebody who has spent an evening organising their rooms
//! in one client expects to open another and find it organised.
//!
//! Only those two are acted on. The spec allows arbitrary user tags
//! (`u.holidays`), which are somebody else's filing system and mean nothing
//! to a client that cannot show them - they are left alone rather than
//! dropped, since the tag list is replaced whole and writing back only the
//! two understood here would quietly delete the rest.
use super::*;
/// The spec's two sorting tags, spelled the way the spec spells them.
///
/// `m.lowpriority` has no dot in the middle. Guessing at `m.low_priority` is
/// a tag no other client reads and a room that stays where it was.
pub const FAVOURITE: &str = "m.favourite";
pub const LOW_PRIORITY: &str = "m.lowpriority";
/// Whether a room is starred, and whether it is pushed down.
pub fn read(content: &Value) -> (bool, bool) {
let tags = &content["tags"];
(tags.get(FAVOURITE).is_some(), tags.get(LOW_PRIORITY).is_some())
}
/// Applies a room's `m.tag` to the buffer it belongs to.
pub(super) fn apply(state: &AppState, account_id: &str, room_id: &str, content: &Value) {
let Some(buffer_id) = state.runtime.matrix_buffer_for_room(account_id, room_id) else { return };
let (favourite, low_priority) = read(content);
state.runtime.set_buffer_tags(state, &buffer_id, favourite, low_priority);
}
/// Reads every room's tags at connect.
///
/// The same shape as the receipt and invite fetches beside it, and for the
/// same reason: a resumed session is told only what has changed since its
/// cursor, and a tag set months ago has not changed. Without this, an account
/// that had organised its rooms elsewhere would see that organisation only
/// after somebody happened to re-tag a room.
///
/// One sync rather than a request per room - a `/user/.../rooms/.../tags` for
/// each of eighty rooms is eighty requests to learn that four of them are
/// favourites.
pub(super) async fn fetch(state: &AppState, account_id: &str, homeserver_url: &str, access_token: &str) -> Result<()> {
let filter = serde_json::json!({
// Nothing but the account data. A timeline limit of zero is not
// something every homeserver will agree to, hence one.
"room": { "timeline": { "limit": 1 }, "state": { "types": [] }, "ephemeral": { "types": [] } },
"presence": { "types": [] }
})
.to_string();
let url = format!(
"{}/_matrix/client/v3/sync?timeout=0&filter={}",
homeserver_url.trim_end_matches('/'),
url::form_urlencoded::byte_serialize(filter.as_bytes()).collect::<String>()
);
let resp = http::get_json(&url, access_token).await.context("tag sync")?;
let Some(joined) = resp["rooms"]["join"].as_object() else { return Ok(()) };
for (room_id, room) in joined {
for event in room["account_data"]["events"].as_array().into_iter().flatten() {
if event["type"].as_str() == Some("m.tag") {
apply(state, account_id, room_id, &event["content"]);
}
}
}
Ok(())
}
/// Adds or removes one tag on one room.
///
/// One tag at a time, which is what the endpoint offers - and it is also the
/// right granularity: the tag list is shared with every other client, and
/// replacing it whole to change one entry would drop tags this client does
/// not understand.
pub async fn set(state: &AppState, account_id: &str, buffer_id: &str, tag: &str, on: bool) -> Result<()> {
if tag != FAVOURITE && tag != LOW_PRIORITY {
anyhow::bail!("{tag} is not a tag this client sets");
}
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
let room_id = state.runtime.get_matrix_room(buffer_id).context("no room for this conversation")?;
let url = format!(
"{}/_matrix/client/v3/user/{}/rooms/{}/tags/{}",
account.homeserver_url.trim_end_matches('/'),
url::form_urlencoded::byte_serialize(account.user_id.as_bytes()).collect::<String>(),
url::form_urlencoded::byte_serialize(room_id.as_bytes()).collect::<String>(),
url::form_urlencoded::byte_serialize(tag.as_bytes()).collect::<String>(),
);
if on {
// An order is optional and this client does not offer one - the rooms
// inside a band keep the recency order they already had. Sending a
// middling one rather than none so a client that *does* sort by it
// has something to sort by.
http::put_json(&url, &account.access_token, serde_json::json!({ "order": 0.5 })).await.context("adding the tag")?;
} else {
http::delete_json(&url, &account.access_token).await.context("removing the tag")?;
}
// Shown now rather than when the next sync says so. The server echoes the
// change back as room account data, which re-applies the same answer -
// this only means the rail moves when the menu item is clicked.
let (mut favourite, mut low_priority) = state.runtime.buffer_tags(buffer_id);
if tag == FAVOURITE {
favourite = on;
} else {
low_priority = on;
}
state.runtime.set_buffer_tags(state, buffer_id, favourite, low_priority);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_room_is_read_as_starred_or_pushed_down() {
let starred = json!({ "tags": { "m.favourite": { "order": 0.1 } } });
assert_eq!(read(&starred), (true, false));
let quiet = json!({ "tags": { "m.lowpriority": {} } });
assert_eq!(read(&quiet), (false, true));
// Somebody else's filing system is not one of ours, and is not an
// error either.
let theirs = json!({ "tags": { "u.holidays": { "order": 0.2 } } });
assert_eq!(read(&theirs), (false, false));
// A room with no tags, and content that is not what was expected.
assert_eq!(read(&json!({ "tags": {} })), (false, false));
assert_eq!(read(&json!({})), (false, false));
}
/// The spelling is the whole of this. `m.lowpriority` has no dot in the
/// middle, and a client writing `m.low_priority` writes a tag nothing
/// else reads.
#[test]
fn the_tags_are_spelled_the_way_the_spec_spells_them() {
assert_eq!(FAVOURITE, "m.favourite");
assert_eq!(LOW_PRIORITY, "m.lowpriority");
assert_eq!(read(&json!({ "tags": { "m.low_priority": {} } })), (false, false));
}
}
+16
View File
@@ -268,6 +268,20 @@ pub struct Buffer {
/// would put an un-mute in front of somebody that does nothing.
#[serde(rename = "serverMuted", default, skip_serializing_if = "std::ops::Not::not")]
pub server_muted: bool,
/// Starred on the account itself: Matrix's `m.favourite` tag today.
///
/// The account's own filing rather than this window's, and the reason it
/// is carried separately from the local pin: a pin is a thing somebody
/// did here, while this was very likely done in another client and is
/// what they expect to find when they open this one. Both raise a
/// conversation to the top of the list.
#[serde(rename = "favourite", default, skip_serializing_if = "std::ops::Not::not")]
pub favourite: bool,
/// The other end of the same idea: `m.lowpriority`, a room the account
/// has pushed to the bottom of its list. Not muted - it still says when
/// somebody speaks, it simply stops competing for the top of the rail.
#[serde(rename = "lowPriority", default, skip_serializing_if = "std::ops::Not::not")]
pub low_priority: bool,
}
/// The rail entry a buffer belongs to when its protocol has no grouping of
@@ -800,6 +814,8 @@ mod tests {
group_id: None,
remote_id: None,
server_muted: false,
favourite: false,
low_priority: false,
}
}
+18
View File
@@ -4255,6 +4255,24 @@ pub async fn dispatch(
(Some(serde_json::json!(filled)), None)
}
// How the account files a room: starred to the top, pushed to the
// bottom, or neither. Per-room account data, so it travels - which is
// the point, and why this is not the window's own pin.
"setMatrixRoomTag" => {
let (buffer_id, tag) = match (p_str_opt(params, "bufferId"), p_str_opt(params, "tag")) {
(Some(b), Some(t)) => (b, t),
_ => return (None, Some("setMatrixRoomTag requires \"bufferId\" and \"tag\"".to_string())),
};
let on = params.get("on").and_then(|v| v.as_bool()).unwrap_or(true);
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
return (None, Some("no such buffer".to_string()));
};
match backend::matrix::tags::set(state, &buffer.account_id, buffer_id, tag, on).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e.to_string())),
}
}
// The account's own stickers, from the packs it carries and the ones
// its rooms share.
"listMatrixStickers" => {
+30
View File
@@ -1360,6 +1360,10 @@ impl Runtime {
// Whichever backend knows says so; a buffer is not muted until
// the service tells us it is.
server_muted: false,
// Likewise: the account's own filing arrives with the room's
// tags, which is after the buffer exists.
favourite: false,
low_priority: false,
};
buffers.insert(id, buffer.clone());
state.events.emit("bufferListChange", serde_json::to_value(&buffer).unwrap());
@@ -1373,6 +1377,32 @@ impl Runtime {
/// account newly calls a direct message - `m.direct` can be written from
/// another client at any time, and a conversation that was an ordinary
/// room a moment ago belongs with the conversations from now on.
/// How the account itself has filed this conversation: starred, pushed
/// down, or neither.
///
/// Both at once because they arrive together - `m.tag` is one event
/// listing every tag a room has - and because they are mutually
/// exclusive in practice: setting one in Element clears the other, and
/// applying them one at a time would draw a room as both for an instant.
pub fn set_buffer_tags(&self, state: &AppState, buffer_id: &str, favourite: bool, low_priority: bool) {
let updated = {
let mut buffers = self.buffers.lock().unwrap();
let Some(buffer) = buffers.get_mut(buffer_id) else { return };
if buffer.favourite == favourite && buffer.low_priority == low_priority {
return;
}
buffer.favourite = favourite;
buffer.low_priority = low_priority;
buffer.clone()
};
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
}
/// What this conversation is currently filed as.
pub fn buffer_tags(&self, buffer_id: &str) -> (bool, bool) {
self.buffers.lock().unwrap().get(buffer_id).map(|b| (b.favourite, b.low_priority)).unwrap_or((false, false))
}
pub fn set_buffer_kind(&self, state: &AppState, buffer_id: &str, kind: &str) {
let updated = {
let mut buffers = self.buffers.lock().unwrap();