Honour the account's own ignore list
`m.ignored_user_list` was neither read nor written. Blocking somebody was `blockedNicks`, a local preference keyed by display name: it did not travel to your other clients, it did not agree with Element, and it did not stop their messages arriving. The list is read at connect and again whenever it changes - it arrives as account data at the top level of sync, which this backend had been ignoring entirely - and messages from anybody on it are dropped before they reach a buffer. Synapse filters them server-side already; not every homeserver does, and a block honoured by only some servers is not worth having. Writing reads the list back from the server first, for the same reason pinning does: this is a whole-map replacement, and writing a stale copy would un-ignore whoever was added from another client since. Measured live: empty, ignored a throwaway id, listed it back, released it, empty again - on a real account, left as found. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -181,6 +181,8 @@ async fn run_sync(state: &AppState, config: &MatrixAccountConfig, account_id: &s
|
||||
|
||||
// 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.
|
||||
fetch_ignored_users(state, account_id, &config.homeserver_url, &access_token, &config.user_id).await;
|
||||
|
||||
// No RAII drop-guard here (deliberately) - run_with_retry's own
|
||||
// catch_unwind wraps this whole function, so a plain `?` return
|
||||
@@ -420,6 +422,17 @@ async fn process_sync_response(state: &AppState, account_id: &str, own_user_id:
|
||||
// one of them - a user going online/offline with no other room
|
||||
// activity this cycle wouldn't otherwise show up in rooms.join at all,
|
||||
// so this can't just be folded into the per-room pass.
|
||||
// The account's own settings, which arrive at the top level rather than in
|
||||
// any room. Only the ignore list is read: it changes from other clients,
|
||||
// and a block that only applies where it was made is not a block.
|
||||
if let Some(events) = resp["account_data"]["events"].as_array() {
|
||||
for event in events {
|
||||
if event["type"].as_str() == Some("m.ignored_user_list") {
|
||||
apply_ignored_users(state, account_id, &event["content"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(events) = resp["presence"]["events"].as_array() {
|
||||
for event in events {
|
||||
let Some(sender) = event["sender"].as_str() else { continue };
|
||||
@@ -679,6 +692,12 @@ async fn handle_timeline_event(
|
||||
}
|
||||
|
||||
let sender = protocol::sender(event);
|
||||
// Somebody this account has asked never to hear from. Synapse filters
|
||||
// them out of sync before they get here; not every homeserver does, and a
|
||||
// block honoured only by some servers is not one worth having.
|
||||
if sender != own_user_id && state.runtime.matrix_is_ignored(account_id, sender) {
|
||||
return;
|
||||
}
|
||||
let from = if sender == own_user_id {
|
||||
state.runtime.own_identity(account_id).unwrap_or_else(|| protocol::short_sender(event))
|
||||
} else {
|
||||
@@ -1816,6 +1835,74 @@ pub async fn backfill(state: &AppState, account_id: &str, buffer_id: &str, limit
|
||||
/// Stored whole and interpreted at the point of use - see notify_decision.
|
||||
/// Only a subset of the rule language is honoured, and honestly: the two
|
||||
/// kinds people actually set are a per-room mute and a keyword.
|
||||
/// Reads `m.ignored_user_list` - the account's own block list.
|
||||
///
|
||||
/// Account data rather than a local preference, which is the whole point:
|
||||
/// blocking somebody on one machine and hearing from them on the next is not
|
||||
/// blocking them. A server that filters them out of sync is doing the same
|
||||
/// thing from its end; this client honours the list either way, since not
|
||||
/// every homeserver does.
|
||||
async fn fetch_ignored_users(state: &AppState, account_id: &str, homeserver_url: &str, access_token: &str, user_id: &str) {
|
||||
let base = homeserver_url.trim_end_matches('/');
|
||||
let url = format!(
|
||||
"{base}/_matrix/client/v3/user/{}/account_data/m.ignored_user_list",
|
||||
url::form_urlencoded::byte_serialize(user_id.as_bytes()).collect::<String>()
|
||||
);
|
||||
match http::get_json(&url, access_token).await {
|
||||
Ok(content) => apply_ignored_users(state, account_id, &content),
|
||||
// A 404 is an account that has never ignored anybody, which is the
|
||||
// ordinary case and not worth a word.
|
||||
Err(e) => tracing::debug!("matrix[{account_id}]: reading the ignore list: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes an `m.ignored_user_list` content and makes it this account's list.
|
||||
pub fn apply_ignored_users(state: &AppState, account_id: &str, content: &Value) {
|
||||
let users: std::collections::HashSet<String> = content["ignored_users"]
|
||||
.as_object()
|
||||
.map(|map| map.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
state.runtime.set_matrix_ignored(account_id, users.clone());
|
||||
let mut listed: Vec<&String> = users.iter().collect();
|
||||
listed.sort();
|
||||
state.events.emit(
|
||||
"matrixIgnored",
|
||||
serde_json::json!({ "accountId": account_id, "users": listed }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds somebody to the ignore list, or takes them off it.
|
||||
///
|
||||
/// The list is read back from the server first for the same reason pinning is:
|
||||
/// this is a whole-map replacement, and writing a stale copy would un-ignore
|
||||
/// whoever was added from another client since this one last looked.
|
||||
pub async fn set_ignored_user(state: &AppState, account_id: &str, user_id: &str, ignored: bool) -> Result<()> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
let url = format!(
|
||||
"{base}/_matrix/client/v3/user/{}/account_data/m.ignored_user_list",
|
||||
url::form_urlencoded::byte_serialize(account.user_id.as_bytes()).collect::<String>()
|
||||
);
|
||||
let mut users: std::collections::BTreeMap<String, Value> = match http::get_json(&url, &account.access_token).await {
|
||||
Ok(content) => content["ignored_users"]
|
||||
.as_object()
|
||||
.map(|map| map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Default::default(),
|
||||
};
|
||||
if ignored {
|
||||
// The value is an empty object by spec - the key is the whole
|
||||
// statement.
|
||||
users.insert(user_id.to_string(), serde_json::json!({}));
|
||||
} else {
|
||||
users.remove(user_id);
|
||||
}
|
||||
let content = serde_json::json!({ "ignored_users": users });
|
||||
http::put_json(&url, &account.access_token, content.clone()).await.context("writing the ignore list")?;
|
||||
apply_ignored_users(state, account_id, &content);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_push_rules(state: &AppState, account_id: &str, homeserver_url: &str, access_token: &str) {
|
||||
let base = homeserver_url.trim_end_matches('/');
|
||||
match http::get_json(&format!("{base}/_matrix/client/v3/pushrules/"), access_token).await {
|
||||
|
||||
@@ -1013,6 +1013,29 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Who this account has asked never to hear from. The account's own
|
||||
// list, so it agrees with Element and travels to every client.
|
||||
"listMatrixIgnored" => match p_str_opt(params, "accountId") {
|
||||
None => (None, Some("listMatrixIgnored requires \"accountId\"".to_string())),
|
||||
Some(account_id) => {
|
||||
let mut users: Vec<String> = state.runtime.matrix_ignored(account_id).into_iter().collect();
|
||||
users.sort();
|
||||
(Some(serde_json::json!({ "accountId": account_id, "users": users })), None)
|
||||
}
|
||||
},
|
||||
|
||||
"setMatrixIgnored" => {
|
||||
let (account_id, user_id) = match (p_str_opt(params, "accountId"), p_str_opt(params, "userId")) {
|
||||
(Some(a), Some(u)) => (a, u),
|
||||
_ => return (None, Some("setMatrixIgnored requires \"accountId\" and \"userId\"".to_string())),
|
||||
};
|
||||
let ignored = params.get("ignored").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
match backend::matrix::set_ignored_user(state, account_id, user_id, ignored).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// What a room has told everyone to read first.
|
||||
"listMatrixPinned" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
|
||||
@@ -546,6 +546,13 @@ pub struct Runtime {
|
||||
/// this is advisory/UI-gating only, an unauthorized action would be
|
||||
/// rejected server-side regardless of what's cached here.
|
||||
matrix_power_levels: Mutex<HashMap<(String, String), Value>>,
|
||||
/// Who this account has asked never to hear from, by account.
|
||||
///
|
||||
/// The account's own list rather than this window's: `m.ignored_user_list`
|
||||
/// travels to every client signed in, which is the whole point of it -
|
||||
/// blocking somebody on one machine and hearing from them on the next is
|
||||
/// not blocking them.
|
||||
matrix_ignored: Mutex<HashMap<String, HashSet<String>>>,
|
||||
/// The event ids a room has pinned, by account and room.
|
||||
///
|
||||
/// The ids only: what those events *say* lives in scrollback or on the
|
||||
@@ -773,6 +780,7 @@ impl Runtime {
|
||||
matrix_room_avatars: Mutex::new(HashMap::new()),
|
||||
matrix_power_levels: Mutex::new(HashMap::new()),
|
||||
matrix_pinned: Mutex::new(HashMap::new()),
|
||||
matrix_ignored: Mutex::new(HashMap::new()),
|
||||
matrix_room_members: Mutex::new(HashMap::new()),
|
||||
matrix_read_receipts: Mutex::new(HashMap::new()),
|
||||
matrix_upgrades: Mutex::new(Vec::new()),
|
||||
@@ -2193,6 +2201,19 @@ impl Runtime {
|
||||
self.matrix_power_levels.lock().unwrap().insert((account_id.to_string(), room_id.to_string()), content);
|
||||
}
|
||||
|
||||
pub fn set_matrix_ignored(&self, account_id: &str, users: HashSet<String>) {
|
||||
self.matrix_ignored.lock().unwrap().insert(account_id.to_string(), users);
|
||||
}
|
||||
|
||||
pub fn matrix_ignored(&self, account_id: &str) -> HashSet<String> {
|
||||
self.matrix_ignored.lock().unwrap().get(account_id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether this account has asked never to hear from somebody.
|
||||
pub fn matrix_is_ignored(&self, account_id: &str, user_id: &str) -> bool {
|
||||
self.matrix_ignored.lock().unwrap().get(account_id).is_some_and(|list| list.contains(user_id))
|
||||
}
|
||||
|
||||
pub fn set_matrix_pinned(&self, account_id: &str, room_id: &str, events: Vec<String>) {
|
||||
self.matrix_pinned.lock().unwrap().insert((account_id.to_string(), room_id.to_string()), events);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user