Ignoring somebody, on all five services rather than one
Matrix kept an ignore list on the homeserver and everywhere else the answer to somebody tiresome was to leave. The menu entry offered it on one service out of five, and the "Block" beside it in the member list was a list in this window's own preferences that hid the lines and nothing else - they still counted as unread, still notified, still went into the log. One request now, answered differently by each service and honest about which. Matrix keeps its list where it always did. Discord gets a real block - a relationship of type 2, which is what its own Block does - *and* a local entry, because its gateway goes on delivering what a blocked person says and this client is what has to stop showing it. IRC, Kick and Sneedchat have nobody to tell, so the list is kept here. Applied in record_message_at, where every message already passes, because a window can hide a line and cannot stop it counting as unread or notifying. Never your own messages, whatever the list says. `/ignore` and `/unignore` are the same thing typed, on every service - including Sneedchat, where the daemon acts on the line and returns before anything is sent, so nothing of it reaches a site that reads messages for commands. A nick is a rented name on IRC and somebody who changes it walks straight back through. That hole is thirty years old and is not closed here; patterns take a `*`, which is what catches a family of bots. Closes #131. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -1134,6 +1134,36 @@ pub async fn answer_friend_request(state: &AppState, account_id: &str, user_id:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Blocks somebody, or lifts it.
|
||||
///
|
||||
/// A relationship of type 2, which is what Discord's own "Block" does: it is
|
||||
/// account-wide, it follows to every client, and - unlike an ignore kept here
|
||||
/// - the person is told, in the sense that their messages to you stop being
|
||||
/// delivered and their friend requests stop arriving.
|
||||
///
|
||||
/// The gateway's own RELATIONSHIP_ADD says it happened, the same way it does
|
||||
/// for a friend; this only has to make the request.
|
||||
pub async fn set_blocked(state: &AppState, account_id: &str, user_id: &str, blocked: bool) -> Result<()> {
|
||||
let cfg = state.accounts.get_discord(account_id).context("account not connected")?;
|
||||
let http = http_client();
|
||||
let url = format!("{API_BASE}/users/@me/relationships/{user_id}");
|
||||
let doing = if blocked { "blocking them" } else { "unblocking them" };
|
||||
let request = if blocked {
|
||||
http.put(url).json(&json!({ "type": 2 }))
|
||||
} else {
|
||||
// The same DELETE that unfriends: what it undoes is whichever
|
||||
// relationship you were in.
|
||||
http.delete(url)
|
||||
};
|
||||
let resp = send_write(request.header("Authorization", &cfg.token)).await.context(doing)?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("{}", discord_error_text(status, &text, doing));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a brand-new guild owned by this account - Discord's own "Create
|
||||
/// My Own" server flow, same endpoint real clients use. Discord auto-
|
||||
/// creates a default #general channel; the gateway's own GUILD_CREATE
|
||||
|
||||
@@ -1926,6 +1926,7 @@ mod tests {
|
||||
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(dir.join("dcc.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(dir.join("ignores.toml"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -255,6 +255,7 @@ mod tests {
|
||||
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
let config = crate::accounts::MatrixAccountConfig {
|
||||
homeserver_url: homeserver.to_string(),
|
||||
|
||||
@@ -4024,6 +4024,7 @@ mod tests {
|
||||
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
let config = MatrixAccountConfig {
|
||||
@@ -4140,6 +4141,7 @@ mod tests {
|
||||
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
let config = MatrixAccountConfig {
|
||||
@@ -4250,6 +4252,7 @@ mod tests {
|
||||
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
let config_a = MatrixAccountConfig {
|
||||
@@ -4405,6 +4408,7 @@ mod tests {
|
||||
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
let config = MatrixAccountConfig {
|
||||
@@ -4559,6 +4563,7 @@ mod tests {
|
||||
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
let config = MatrixAccountConfig {
|
||||
|
||||
@@ -537,6 +537,7 @@ mod tests {
|
||||
voice_prefs: Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
highlights: Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
let config = MatrixAccountConfig {
|
||||
homeserver_url: homeserver.to_string(),
|
||||
|
||||
+21
-4
@@ -27,6 +27,9 @@ pub struct Builtin {
|
||||
const IRC: &[&str] = &["irc"];
|
||||
const IRC_AND_MATRIX: &[&str] = &["irc", "matrix"];
|
||||
const MATRIX: &[&str] = &["matrix"];
|
||||
/// Everywhere, including the services with no list of their own - moho keeps
|
||||
/// one for those.
|
||||
const EVERY: &[&str] = &["irc", "matrix", "discord", "kick", "sneedchat"];
|
||||
/// The ones that are only text, and so work wherever this daemon does the
|
||||
/// sending itself.
|
||||
///
|
||||
@@ -57,6 +60,13 @@ pub const BUILTINS: &[Builtin] = &[
|
||||
description: "Send a place",
|
||||
services: MATRIX,
|
||||
},
|
||||
Builtin {
|
||||
name: "ignore",
|
||||
usage: "[name]",
|
||||
description: "Stop hearing from somebody, or list who is ignored",
|
||||
services: EVERY,
|
||||
},
|
||||
Builtin { name: "unignore", usage: "<name>", description: "Hear from somebody again", services: EVERY },
|
||||
Builtin { name: "notify", usage: "[nick]", description: "Watch for somebody arriving, or list who is watched", services: IRC },
|
||||
Builtin { name: "unnotify", usage: "<nick>", description: "Stop watching for somebody", services: IRC },
|
||||
Builtin { name: "whowas", usage: "<nick>", description: "Look up somebody who has left", services: IRC },
|
||||
@@ -216,14 +226,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sneedchat's own command, and only its own: the site reads a message
|
||||
/// for commands before anybody else does, so four invented here would
|
||||
/// mean the same typing doing different things in different clients.
|
||||
/// Sneedchat's own command, and nothing invented for it to send: the site
|
||||
/// reads a message for commands before anybody else does, so a command
|
||||
/// this client made up would be posted as public text.
|
||||
///
|
||||
/// `/ignore` is the one exception and is not an exception to that rule:
|
||||
/// the daemon acts on it and returns before anything is sent, so nothing
|
||||
/// of it ever reaches the site.
|
||||
#[test]
|
||||
fn sneedchat_is_offered_its_own_command_and_nothing_invented() {
|
||||
let names: Vec<&str> = matching("sneedchat", "").iter().map(|c| c.name).collect();
|
||||
assert_eq!(names, vec!["w"]);
|
||||
assert_eq!(names, vec!["ignore", "unignore", "w"]);
|
||||
assert_eq!(super::rewrite("sneedchat", "/tableflip"), None);
|
||||
// And no rewrite for the ones it does offer, which is what would put
|
||||
// them on the wire.
|
||||
assert_eq!(super::rewrite("sneedchat", "/ignore somebody"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
//! People whose messages should not arrive, on the services that will not do
|
||||
//! it for you.
|
||||
//!
|
||||
//! Matrix keeps an ignore list on the homeserver, which is the right place for
|
||||
//! one: it follows the account to every client and the server stops sending
|
||||
//! the messages at all. Nothing else here has that. Discord has blocking,
|
||||
//! which is a real relationship and visible to the person blocked, but its
|
||||
//! gateway still delivers what they say - the official client is what hides
|
||||
//! it. IRC, Kick and Sneedchat have nothing whatsoever.
|
||||
//!
|
||||
//! So this is the local half: a list per account, applied where every message
|
||||
//! already passes - `Runtime::record_message_at`. Held in the daemon rather
|
||||
//! than in a window for the same reason the highlight keywords are: a window
|
||||
//! could hide a line and could not stop it counting as unread, notifying, or
|
||||
//! being written into the log.
|
||||
//!
|
||||
//! Matched on the name and on the protocol id where there is one. A pattern
|
||||
//! may carry `*`, because "everything from this bot family" is a real thing
|
||||
//! to want on IRC - though a nick is only ever a rented name there, and
|
||||
//! somebody who changes it walks straight back through. Say so rather than
|
||||
//! pretending otherwise: this is the ignore every IRC client has had for
|
||||
//! thirty years, and it has always had that hole in it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The file's contents, at `~/.config/nobilis/ignores.toml`.
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct IgnorePrefs {
|
||||
/// Who is ignored, by account id.
|
||||
#[serde(default)]
|
||||
pub accounts: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
pub struct IgnoreStore {
|
||||
path: PathBuf,
|
||||
inner: Mutex<IgnorePrefs>,
|
||||
}
|
||||
|
||||
impl IgnoreStore {
|
||||
pub fn open(path: PathBuf) -> Self {
|
||||
let inner = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|t| toml::from_str(&t).ok())
|
||||
// A file that will not parse must not stop the daemon starting -
|
||||
// ignoring nobody is a working configuration.
|
||||
.unwrap_or_default();
|
||||
Self { path, inner: Mutex::new(inner) }
|
||||
}
|
||||
|
||||
pub fn for_account(&self, account_id: &str) -> Vec<String> {
|
||||
self.inner.lock().unwrap().accounts.get(account_id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Adds or removes one entry, and says whether anything changed.
|
||||
pub fn set(&self, account_id: &str, target: &str, ignored: bool) -> bool {
|
||||
let target = target.trim();
|
||||
if target.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
self.update(|prefs| {
|
||||
let list = prefs.accounts.entry(account_id.to_string()).or_default();
|
||||
let known = list.iter().position(|t| t.eq_ignore_ascii_case(target));
|
||||
match (known, ignored) {
|
||||
(None, true) => {
|
||||
list.push(target.to_string());
|
||||
changed = true;
|
||||
}
|
||||
(Some(at), false) => {
|
||||
list.remove(at);
|
||||
changed = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// An empty list and no list mean the same thing, and only one of
|
||||
// them leaves a row in the file behind.
|
||||
if list.is_empty() {
|
||||
prefs.accounts.remove(account_id);
|
||||
}
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
/// Drops an account's list, for an account being removed.
|
||||
pub fn forget(&self, account_id: &str) {
|
||||
self.update(|prefs| {
|
||||
prefs.accounts.remove(account_id);
|
||||
});
|
||||
}
|
||||
|
||||
fn update(&self, edit: impl FnOnce(&mut IgnorePrefs)) {
|
||||
let mut prefs = self.inner.lock().unwrap();
|
||||
edit(&mut prefs);
|
||||
let Ok(text) = toml::to_string_pretty(&*prefs) else { return };
|
||||
if let Some(dir) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
// Through a temporary file, so an interrupted write cannot leave half
|
||||
// a list behind.
|
||||
let tmp = self.path.with_extension("toml.tmp");
|
||||
if std::fs::write(&tmp, text).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this sender is on the list.
|
||||
///
|
||||
/// The name and the protocol id are both checked, because which one somebody
|
||||
/// ignored depends on where they pressed it: a member list knows the id, and
|
||||
/// a typed `/ignore` knows only the name.
|
||||
pub fn is_ignored(list: &[String], from: &str, sender_id: Option<&str>) -> bool {
|
||||
list.iter().any(|pattern| {
|
||||
matches_pattern(pattern, from) || sender_id.is_some_and(|id| matches_pattern(pattern, id))
|
||||
})
|
||||
}
|
||||
|
||||
/// One pattern against one name, case-insensitively, with `*` for anything.
|
||||
///
|
||||
/// Written out rather than pulled in as a dependency: the whole grammar is one
|
||||
/// wildcard, and a glob crate would be a dependency for six lines.
|
||||
fn matches_pattern(pattern: &str, name: &str) -> bool {
|
||||
if !pattern.contains('*') {
|
||||
return pattern.eq_ignore_ascii_case(name);
|
||||
}
|
||||
let pattern = pattern.to_lowercase();
|
||||
let name = name.to_lowercase();
|
||||
let mut rest = name.as_str();
|
||||
let parts: Vec<&str> = pattern.split('*').collect();
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match rest.find(part) {
|
||||
None => return false,
|
||||
Some(at) => {
|
||||
// A pattern not starting with `*` is anchored at the front,
|
||||
// or "*bot" would match "robot" and "bottle" alike.
|
||||
if i == 0 && at != 0 {
|
||||
return false;
|
||||
}
|
||||
rest = &rest[at + part.len()..];
|
||||
}
|
||||
}
|
||||
}
|
||||
// And anchored at the end unless it finishes with a wildcard.
|
||||
parts.last().is_some_and(|last| last.is_empty() || rest.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_name_or_an_id_is_enough() {
|
||||
let list = vec!["Spammer".to_string(), "1234567890".to_string()];
|
||||
assert!(is_ignored(&list, "spammer", None));
|
||||
assert!(is_ignored(&list, "somebody", Some("1234567890")));
|
||||
assert!(!is_ignored(&list, "somebody", Some("999")));
|
||||
assert!(!is_ignored(&[], "spammer", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_star_stands_for_anything() {
|
||||
assert!(matches_pattern("spam*", "spambot9000"));
|
||||
assert!(matches_pattern("*bot", "helpbot"));
|
||||
assert!(matches_pattern("*", "anybody at all"));
|
||||
assert!(matches_pattern("a*c", "abbbc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_star_does_not_stand_for_everything() {
|
||||
// Anchored at both ends unless the pattern says otherwise, or
|
||||
// "*bot" would take "bottle" with it.
|
||||
assert!(!matches_pattern("bot*", "robot"));
|
||||
assert!(!matches_pattern("*bot", "bottle"));
|
||||
assert!(!matches_pattern("spam", "spambot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_twice_changes_nothing_the_second_time() {
|
||||
let dir = std::env::temp_dir().join(format!("moho-ignores-{}", std::process::id()));
|
||||
let store = IgnoreStore::open(dir.join("ignores.toml"));
|
||||
assert!(store.set("irc:a", "Spammer", true));
|
||||
assert!(!store.set("irc:a", "spammer", true));
|
||||
assert_eq!(store.for_account("irc:a"), vec!["Spammer"]);
|
||||
// And removing it leaves no row behind, so the file does not grow a
|
||||
// list of empty lists.
|
||||
assert!(store.set("irc:a", "SPAMMER", false));
|
||||
assert!(store.for_account("irc:a").is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod backend;
|
||||
mod commands;
|
||||
mod events;
|
||||
mod highlights;
|
||||
mod ignores;
|
||||
mod ipc;
|
||||
mod model;
|
||||
mod net;
|
||||
@@ -213,6 +214,7 @@ async fn main() -> Result<()> {
|
||||
voice_prefs: Arc::new(backend::audio::VoicePrefsStore::open(opts.data_dir.join("voice.toml"))),
|
||||
dcc_prefs: Arc::new(backend::irc_dcc::DccPrefsStore::open(opts.data_dir.join("dcc.toml"))),
|
||||
highlights: Arc::new(highlights::HighlightStore::open(opts.data_dir.join("highlights.toml"))),
|
||||
ignores: Arc::new(ignores::IgnoreStore::open(opts.data_dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
// Reconnect every saved account, same as
|
||||
|
||||
@@ -28,6 +28,38 @@ fn kick_credential(state: &AppState, account_id: &str) -> std::result::Result<(r
|
||||
Ok((http, token))
|
||||
}
|
||||
|
||||
/// Stops somebody's messages arriving, on whichever service they are on.
|
||||
///
|
||||
/// One place rather than two, because the menu entry and the typed command
|
||||
/// are the same request. Answers with where the ignore holds: "account" when
|
||||
/// the service itself was told and it follows the account everywhere, "window"
|
||||
/// when there was nobody to tell and this is moho declining to show what still
|
||||
/// arrives.
|
||||
async fn apply_ignore(state: &AppState, account_id: &str, target: &str, ignored: bool) -> anyhow::Result<&'static str> {
|
||||
match crate::model::service_of(account_id) {
|
||||
// The homeserver keeps the list and stops sending the messages, so
|
||||
// there is nothing to keep here.
|
||||
"matrix" => {
|
||||
backend::matrix::set_ignored_user(state, account_id, target, ignored).await?;
|
||||
Ok("account")
|
||||
}
|
||||
// Discord has a real block. It is told as well as recorded, because a
|
||||
// block is account-wide and people expect it to hold in the phone app
|
||||
// too - and recorded as well as told, because the gateway keeps
|
||||
// delivering what a blocked person says and it is this client that
|
||||
// has to stop showing it.
|
||||
"discord" => {
|
||||
backend::discord::set_blocked(state, account_id, target, ignored).await?;
|
||||
state.ignores.set(account_id, target, ignored);
|
||||
Ok("account")
|
||||
}
|
||||
_ => {
|
||||
state.ignores.set(account_id, target, ignored);
|
||||
Ok("window")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dispatch(
|
||||
state: &AppState,
|
||||
method: &str,
|
||||
@@ -390,6 +422,7 @@ pub async fn dispatch(
|
||||
// they would come back with an account added under the same
|
||||
// name later.
|
||||
state.highlights.forget(id);
|
||||
state.ignores.forget(id);
|
||||
match state.accounts.remove(id) {
|
||||
Ok(true) => (Some(ok_node()), None),
|
||||
Ok(false) => (None, Some("no such account".to_string())),
|
||||
@@ -2205,6 +2238,42 @@ pub async fn dispatch(
|
||||
.get_buffer(buffer_id)
|
||||
.and_then(|b| crate::commands::rewrite(crate::model::service_of(&b.account_id), body));
|
||||
let body: &str = rewritten.as_deref().unwrap_or(body);
|
||||
// Not hearing from somebody, typed rather than clicked. Handled
|
||||
// here rather than in each backend because the answer is the same
|
||||
// on all of them and only one of them - IRC - has ever had the
|
||||
// command: it is the same list the menu writes to.
|
||||
if let Some(buffer) = state.runtime.get_buffer(buffer_id) {
|
||||
if let Some(rest) = body.strip_prefix("/ignore").or_else(|| body.strip_prefix("/unignore")) {
|
||||
let ignoring = body.starts_with("/ignore");
|
||||
let target = rest.trim();
|
||||
let line = if target.is_empty() {
|
||||
// Asking rather than setting, which is what a bare
|
||||
// /ignore means in the clients that have one.
|
||||
let list = state.ignores.for_account(&buffer.account_id);
|
||||
if list.is_empty() {
|
||||
"ignoring nobody - /ignore <name> adds somebody".to_string()
|
||||
} else {
|
||||
format!("ignoring: {}", list.join(", "))
|
||||
}
|
||||
} else {
|
||||
// The same call the menu entry makes, so a typed
|
||||
// ignore and a clicked one cannot mean different
|
||||
// things - including telling Matrix and Discord,
|
||||
// which have lists of their own.
|
||||
match apply_ignore(state, &buffer.account_id, target, ignoring).await {
|
||||
Err(e) => return (None, Some(format!("{e:#}"))),
|
||||
Ok("account") if ignoring => format!("ignoring {target} - on this account everywhere"),
|
||||
Ok(_) if ignoring => format!("ignoring {target} - in moho, since this service has no list of its own"),
|
||||
Ok(_) => format!("no longer ignoring {target}"),
|
||||
}
|
||||
};
|
||||
state.runtime.record_message(
|
||||
state, &buffer.account_id, &buffer.name, &buffer.kind, "*", &line, false, "system",
|
||||
None, None, false, None, Vec::new(), Vec::new(), None,
|
||||
);
|
||||
return (Some(ok_node()), None);
|
||||
}
|
||||
}
|
||||
match state.runtime.get_buffer(buffer_id) {
|
||||
None => (None, Some("sendMessage requires a known \"bufferId\" and \"body\"".to_string())),
|
||||
Some(buffer) if buffer.account_id.starts_with("discord:") => match state.accounts.get_discord(&buffer.account_id) {
|
||||
@@ -3554,6 +3623,37 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Not hearing from somebody, on whichever service they are on.
|
||||
//
|
||||
// One method rather than one per backend, because the person doing it
|
||||
// is answering the same question everywhere - and the difference in
|
||||
// what it costs them is worth saying out loud, which is what the
|
||||
// answer carries back.
|
||||
"setIgnored" => {
|
||||
let (account_id, target) = match (p_str_opt(params, "accountId"), p_str_opt(params, "target")) {
|
||||
(Some(a), Some(t)) => (a, t),
|
||||
_ => return (None, Some("setIgnored requires \"accountId\" and \"target\"".to_string())),
|
||||
};
|
||||
let ignored = params.get("ignored").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
match apply_ignore(state, account_id, target, ignored).await {
|
||||
Ok(scope) => (Some(serde_json::json!({ "scope": scope })), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
"listIgnored" => {
|
||||
let Some(account_id) = p_str_opt(params, "accountId") else {
|
||||
return (None, Some("listIgnored requires \"accountId\"".to_string()));
|
||||
};
|
||||
if crate::model::service_of(account_id) == "matrix" {
|
||||
// The homeserver's list, which the sync already keeps here.
|
||||
let mut users: Vec<String> = state.runtime.matrix_ignored(account_id).into_iter().collect();
|
||||
users.sort();
|
||||
return (Some(serde_json::json!(users)), None);
|
||||
}
|
||||
(Some(serde_json::json!(state.ignores.for_account(account_id))), None)
|
||||
}
|
||||
|
||||
// Asking to be let into a room that is asked rather than entered.
|
||||
"knockMatrixRoom" => {
|
||||
let (account_id, room) = match (p_str_opt(params, "accountId"), p_str_opt(params, "roomIdOrAlias")) {
|
||||
|
||||
@@ -3163,6 +3163,24 @@ impl Runtime {
|
||||
html: Option<String>,
|
||||
style: Option<model::SenderStyle>,
|
||||
) -> bool {
|
||||
// Somebody being ignored, on a service that will not do it for us.
|
||||
// Here rather than in each backend because this is the one place
|
||||
// every message passes, and here rather than in a window because a
|
||||
// window can hide a line and cannot stop it counting as unread,
|
||||
// notifying, or being written into the log.
|
||||
//
|
||||
// Never our own messages, whatever the list says: ignoring yourself
|
||||
// would be a way to lose what you just typed.
|
||||
if kind != "system" && !state.ignores.for_account(account_id).is_empty() {
|
||||
let own = self
|
||||
.irc_current_nick(account_id)
|
||||
.or_else(|| self.own_identity(account_id))
|
||||
.unwrap_or_default();
|
||||
let is_own = !own.is_empty() && from == own;
|
||||
if !is_own && crate::ignores::is_ignored(&state.ignores.for_account(account_id), from, sender_id.as_deref()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let buffer = self.ensure_buffer(state, account_id, buffer_name, buffer_kind);
|
||||
let own_nick = self
|
||||
.irc_current_nick(account_id)
|
||||
|
||||
@@ -35,4 +35,7 @@ pub struct AppState {
|
||||
/// name. Here rather than in a window because this is where a message is
|
||||
/// decided to be a highlight - see highlights.rs.
|
||||
pub highlights: Arc<crate::highlights::HighlightStore>,
|
||||
/// People whose messages should not arrive, on the services with no
|
||||
/// server-side list of their own - see ignores.rs.
|
||||
pub ignores: Arc<crate::ignores::IgnoreStore>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user