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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user