Words that count, besides your name
A message highlighted and notified only if it carried this account's own nick, tagged the room, or was a DM. Every other client here lets you add words - a project, a surname, a name you also answer to - and without it the choice was your nick or nothing. The words live in the daemon because this is where the decision is made: one flag feeds the stored column the mentions inbox reads, the event the client styles from, and the notification. Words kept in a window could colour a line and could never notify anybody. Two lists, in highlights.toml beside voice.toml and dcc.toml: a global one and one per account, added together at match time, since a word that means you on one network is somebody's ordinary vocabulary on another. A side table rather than a sixth field on five account structs - display_name is stored that way and costs a five-branch fan-out twice over - so removing an account now has to forget its words, which removeAccount does. Whole words, case-insensitively, by the same boundary test the local rename already uses: "cat" must not match "catalogue" or a keyword lights up half a channel and is turned off within the hour. Verified live: a message carrying a keyword arrived highlighted and a message carrying it inside a longer word did not. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -778,6 +778,9 @@ pub fn irc_account_to_json(a: &IrcAccountConfig, state: &str) -> Account {
|
||||
// Filled in by Runtime::list_accounts, which is the only place that
|
||||
// can see a live connection.
|
||||
current_nick: String::new(),
|
||||
// Filled in by Runtime::list_accounts, which can see the store these
|
||||
// live in; the constructors here only ever see one account's config.
|
||||
highlight_keywords: Vec::new(),
|
||||
ssl: a.ssl,
|
||||
has_password: a.password.as_deref().is_some_and(|p| !p.is_empty()),
|
||||
avatar_url: None,
|
||||
@@ -807,6 +810,9 @@ pub fn discord_account_to_json(a: &DiscordAccountConfig, state: &str) -> Account
|
||||
sasl_mechanism: String::new(),
|
||||
has_sasl_certificate: false,
|
||||
current_nick: String::new(),
|
||||
// Filled in by Runtime::list_accounts, which can see the store these
|
||||
// live in; the constructors here only ever see one account's config.
|
||||
highlight_keywords: Vec::new(),
|
||||
ssl: true,
|
||||
has_password: !a.token.is_empty(),
|
||||
avatar_url: a.avatar_url.clone(),
|
||||
@@ -835,6 +841,9 @@ pub fn sneedchat_account_to_json(a: &SneedChatAccountConfig, state: &str) -> Acc
|
||||
sasl_mechanism: String::new(),
|
||||
has_sasl_certificate: false,
|
||||
current_nick: String::new(),
|
||||
// Filled in by Runtime::list_accounts, which can see the store these
|
||||
// live in; the constructors here only ever see one account's config.
|
||||
highlight_keywords: Vec::new(),
|
||||
ssl: true,
|
||||
has_password: !a.password.is_empty(),
|
||||
avatar_url: None,
|
||||
@@ -872,6 +881,9 @@ pub fn kick_account_to_json(a: &KickAccountConfig, state: &str) -> Account {
|
||||
sasl_mechanism: String::new(),
|
||||
has_sasl_certificate: false,
|
||||
current_nick: String::new(),
|
||||
// Filled in by Runtime::list_accounts, which can see the store these
|
||||
// live in; the constructors here only ever see one account's config.
|
||||
highlight_keywords: Vec::new(),
|
||||
ssl: true,
|
||||
has_password: a.token.as_deref().is_some_and(|t| !t.is_empty()),
|
||||
avatar_url: None,
|
||||
@@ -904,6 +916,9 @@ pub fn matrix_account_to_json(a: &MatrixAccountConfig, state: &str, has_key_back
|
||||
sasl_mechanism: String::new(),
|
||||
has_sasl_certificate: false,
|
||||
current_nick: String::new(),
|
||||
// Filled in by Runtime::list_accounts, which can see the store these
|
||||
// live in; the constructors here only ever see one account's config.
|
||||
highlight_keywords: Vec::new(),
|
||||
ssl: true,
|
||||
has_password: !a.password.is_empty(),
|
||||
avatar_url: None,
|
||||
|
||||
@@ -1920,6 +1920,7 @@ mod tests {
|
||||
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
|
||||
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"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,7 @@ mod tests {
|
||||
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
|
||||
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"))),
|
||||
};
|
||||
let config = crate::accounts::MatrixAccountConfig {
|
||||
homeserver_url: homeserver.to_string(),
|
||||
|
||||
@@ -3773,6 +3773,7 @@ mod tests {
|
||||
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
|
||||
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"))),
|
||||
};
|
||||
|
||||
let config = MatrixAccountConfig {
|
||||
@@ -3888,6 +3889,7 @@ mod tests {
|
||||
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
|
||||
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"))),
|
||||
};
|
||||
|
||||
let config = MatrixAccountConfig {
|
||||
@@ -3997,6 +3999,7 @@ mod tests {
|
||||
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
|
||||
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"))),
|
||||
};
|
||||
|
||||
let config_a = MatrixAccountConfig {
|
||||
@@ -4151,6 +4154,7 @@ mod tests {
|
||||
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
|
||||
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"))),
|
||||
};
|
||||
|
||||
let config = MatrixAccountConfig {
|
||||
@@ -4304,6 +4308,7 @@ mod tests {
|
||||
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
|
||||
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"))),
|
||||
};
|
||||
|
||||
let config = MatrixAccountConfig {
|
||||
|
||||
@@ -536,6 +536,7 @@ mod tests {
|
||||
voice: Arc::new(crate::backend::discord_voice::VoiceState::new()),
|
||||
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"))),
|
||||
};
|
||||
let config = MatrixAccountConfig {
|
||||
homeserver_url: homeserver.to_string(),
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! The words that make a message worth being told about, besides your name.
|
||||
//!
|
||||
//! Kept here rather than in the client because this is where the decision is
|
||||
//! made: `Runtime::record_message` marks a message highlighted, and that one
|
||||
//! flag feeds the stored column the mentions inbox reads, the event the client
|
||||
//! styles from, and the notification. A keyword held in a window's own
|
||||
//! preferences could colour a line and could never notify anybody, which is
|
||||
//! the whole point of having one.
|
||||
//!
|
||||
//! Two lists. The global one is for words that mean the same wherever they are
|
||||
//! said - a surname, a project - and the per-account ones for words that only
|
||||
//! matter on one network, which is most of them: a name that reaches you on
|
||||
//! IRC is somebody else's ordinary vocabulary on Discord.
|
||||
//!
|
||||
//! A side table keyed by account id rather than a field on each account,
|
||||
//! because there are five account structs and `display_name` already shows
|
||||
//! what that costs - a five-branch fan-out in the setter and another in the
|
||||
//! getter. The price of this shape is that removing an account has to remove
|
||||
//! its words too; `removeAccount` does that.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The file's contents, at `~/.config/nobilis/highlights.toml`.
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct HighlightPrefs {
|
||||
/// Words that count on every account.
|
||||
#[serde(default)]
|
||||
pub global: Vec<String>,
|
||||
/// Words that count on one account, by account id.
|
||||
#[serde(default)]
|
||||
pub accounts: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
pub struct HighlightStore {
|
||||
path: PathBuf,
|
||||
inner: Mutex<HighlightPrefs>,
|
||||
}
|
||||
|
||||
impl HighlightStore {
|
||||
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.
|
||||
// No keywords is a working configuration - it is what everybody
|
||||
// has until they add one.
|
||||
.unwrap_or_default();
|
||||
Self { path, inner: Mutex::new(inner) }
|
||||
}
|
||||
|
||||
/// The words that count on every account.
|
||||
pub fn global(&self) -> Vec<String> {
|
||||
self.inner.lock().unwrap().global.clone()
|
||||
}
|
||||
|
||||
/// The words this one account has of its own.
|
||||
pub fn account(&self, account_id: &str) -> Vec<String> {
|
||||
self.inner.lock().unwrap().accounts.get(account_id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Everything that counts in a conversation on this account: the global
|
||||
/// words and its own, together, since a message only has to match one.
|
||||
pub fn for_account(&self, account_id: &str) -> Vec<String> {
|
||||
let prefs = self.inner.lock().unwrap();
|
||||
let mut out = prefs.global.clone();
|
||||
if let Some(own) = prefs.accounts.get(account_id) {
|
||||
out.extend(own.iter().cloned());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn set_global(&self, words: Vec<String>) {
|
||||
self.update(|prefs| prefs.global = tidy(words));
|
||||
}
|
||||
|
||||
/// Sets one account's words, and forgets the account entirely when the
|
||||
/// list is emptied - an empty list and no list mean the same thing, and
|
||||
/// only one of them leaves a row behind.
|
||||
pub fn set_account(&self, account_id: &str, words: Vec<String>) {
|
||||
self.update(|prefs| {
|
||||
let words = tidy(words);
|
||||
if words.is_empty() {
|
||||
prefs.accounts.remove(account_id);
|
||||
} else {
|
||||
prefs.accounts.insert(account_id.to_string(), words);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Drops an account's words, for an account that is being removed.
|
||||
pub fn forget(&self, account_id: &str) {
|
||||
self.update(|prefs| {
|
||||
prefs.accounts.remove(account_id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Applies a change and writes the file out, through a temporary file so
|
||||
/// an interrupted write cannot leave a half-written one behind.
|
||||
fn update(&self, edit: impl FnOnce(&mut HighlightPrefs)) {
|
||||
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);
|
||||
}
|
||||
let tmp = self.path.with_extension("toml.tmp");
|
||||
if std::fs::write(&tmp, text).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a typed list actually contains once the typing is taken out: no blank
|
||||
/// entries, no surrounding spaces, nothing said twice.
|
||||
fn tidy(words: Vec<String>) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for word in words {
|
||||
let word = word.trim().to_string();
|
||||
if word.is_empty() || out.iter().any(|w| w.eq_ignore_ascii_case(&word)) {
|
||||
continue;
|
||||
}
|
||||
out.push(word);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_typed_list_loses_its_blanks_and_its_repeats() {
|
||||
let words = tidy(vec![" moho ".into(), "".into(), "MOHO".into(), "nobilis".into(), " ".into()]);
|
||||
assert_eq!(words, vec!["moho".to_string(), "nobilis".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accounts_words_are_its_own_plus_everybodys() {
|
||||
let dir = std::env::temp_dir().join(format!("moho-highlights-{}", std::process::id()));
|
||||
let store = HighlightStore::open(dir.join("highlights.toml"));
|
||||
store.set_global(vec!["salastil".into()]);
|
||||
store.set_account("irc:coreirc", vec!["moho".into()]);
|
||||
|
||||
let mut both = store.for_account("irc:coreirc");
|
||||
both.sort();
|
||||
assert_eq!(both, vec!["moho".to_string(), "salastil".to_string()]);
|
||||
// Another account gets the global words and nobody else's.
|
||||
assert_eq!(store.for_account("discord:1"), vec!["salastil".to_string()]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emptying_a_list_removes_it_rather_than_storing_nothing() {
|
||||
let dir = std::env::temp_dir().join(format!("moho-highlights-empty-{}", std::process::id()));
|
||||
let store = HighlightStore::open(dir.join("highlights.toml"));
|
||||
store.set_account("irc:coreirc", vec!["moho".into()]);
|
||||
store.set_account("irc:coreirc", vec![]);
|
||||
assert!(store.inner.lock().unwrap().accounts.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The words have to survive a restart, which is the only thing the file
|
||||
/// is for.
|
||||
#[test]
|
||||
fn what_was_written_is_read_back() {
|
||||
let dir = std::env::temp_dir().join(format!("moho-highlights-rw-{}", std::process::id()));
|
||||
let path = dir.join("highlights.toml");
|
||||
{
|
||||
let store = HighlightStore::open(path.clone());
|
||||
store.set_global(vec!["salastil".into()]);
|
||||
store.set_account("kick:me", vec!["raid".into(), "gifted".into()]);
|
||||
}
|
||||
let reopened = HighlightStore::open(path);
|
||||
assert_eq!(reopened.global(), vec!["salastil".to_string()]);
|
||||
assert_eq!(reopened.account("kick:me"), vec!["raid".to_string(), "gifted".to_string()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod accounts;
|
||||
mod backend;
|
||||
mod events;
|
||||
mod highlights;
|
||||
mod ipc;
|
||||
mod model;
|
||||
mod net;
|
||||
@@ -210,6 +211,7 @@ async fn main() -> Result<()> {
|
||||
voice: Arc::new(backend::discord_voice::VoiceState::new()),
|
||||
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"))),
|
||||
};
|
||||
|
||||
// Reconnect every saved account, same as
|
||||
|
||||
@@ -33,6 +33,11 @@ pub struct Account {
|
||||
/// file on the machine the daemon is running on rather than this one.
|
||||
#[serde(rename = "hasSaslCertificate")]
|
||||
pub has_sasl_certificate: bool,
|
||||
/// The words that make a message here worth being told about, besides
|
||||
/// this account's own name. Reported with the account so the field that
|
||||
/// edits them can be filled in without a second question.
|
||||
#[serde(rename = "highlightKeywords", default)]
|
||||
pub highlight_keywords: Vec<String>,
|
||||
/// The nick this account is actually using right now.
|
||||
///
|
||||
/// Not the same as the configured one: a session that landed on the alt
|
||||
|
||||
@@ -384,6 +384,11 @@ pub async fn dispatch(
|
||||
// Its guilds would otherwise stay in the rail with nothing
|
||||
// under them.
|
||||
state.runtime.clear_buffer_groups_for_account(id);
|
||||
// Its highlight words live in their own file rather than on
|
||||
// the account, so removing the account has to say so - or
|
||||
// they would come back with an account added under the same
|
||||
// name later.
|
||||
state.highlights.forget(id);
|
||||
match state.accounts.remove(id) {
|
||||
Ok(true) => (Some(ok_node()), None),
|
||||
Ok(false) => (None, Some("no such account".to_string())),
|
||||
@@ -456,6 +461,37 @@ pub async fn dispatch(
|
||||
Some(id) => account_mutation_result(state.accounts.set_nickserv_password(id, p_str(params, "password", ""))),
|
||||
},
|
||||
|
||||
// The words that count besides your name.
|
||||
//
|
||||
// Global ones and one account's own are the same call with and
|
||||
// without an account: they are the same kind of thing, and a message
|
||||
// only has to match one of them.
|
||||
"getHighlightKeywords" => (
|
||||
Some(serde_json::json!({ "global": state.highlights.global() })),
|
||||
None,
|
||||
),
|
||||
|
||||
"setHighlightKeywords" => {
|
||||
let Some(words) = params.get("keywords").and_then(|v| v.as_array()) else {
|
||||
return (None, Some("setHighlightKeywords requires \"keywords\"".to_string()));
|
||||
};
|
||||
let words: Vec<String> = words.iter().filter_map(|w| w.as_str().map(str::to_string)).collect();
|
||||
match p_str_opt(params, "accountId").filter(|id| !id.is_empty()) {
|
||||
Some(account_id) => {
|
||||
if state.runtime.list_accounts(state).iter().all(|a| a.id != account_id) {
|
||||
return (None, Some("no such account".to_string()));
|
||||
}
|
||||
// No event: the account carries its own words, and the
|
||||
// client re-reads the accounts after a change it made -
|
||||
// which is how every other per-account setting here
|
||||
// finds its way back onto the screen.
|
||||
state.highlights.set_account(account_id, words);
|
||||
}
|
||||
None => state.highlights.set_global(words),
|
||||
}
|
||||
(Some(serde_json::json!({ "global": state.highlights.global() })), None)
|
||||
}
|
||||
|
||||
"setAccountDisplayName" => match p_str_opt(params, "accountId") {
|
||||
None => (None, Some("no such account".to_string())),
|
||||
Some(id) => {
|
||||
|
||||
+110
-1
@@ -665,6 +665,48 @@ fn mentions_the_room(service: &str, body: &str) -> bool {
|
||||
words.iter().any(|word| lower.contains(word))
|
||||
}
|
||||
|
||||
/// Whether a message contains one of the words somebody asked to be told
|
||||
/// about.
|
||||
///
|
||||
/// Whole words, case-insensitively: "cat" does not match "catalogue", and a
|
||||
/// keyword that matched inside longer words would light up half a busy
|
||||
/// channel and be turned off within the hour. The boundary test is the same
|
||||
/// one `rename_own_mentions` uses below, because a name is exactly the sort
|
||||
/// of thing people put in this list and it has to behave the same way in both
|
||||
/// places.
|
||||
///
|
||||
/// A keyword of several words matches as the phrase, with a boundary at each
|
||||
/// end - "release day" is a thing worth being told about and is two words.
|
||||
fn matches_keyword(body: &str, keywords: &[String]) -> bool {
|
||||
if keywords.is_empty() || body.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// The same "not a boundary" test as the rename below: nick punctuation
|
||||
// counts as part of a word, or `some_word` would match `some`.
|
||||
let joined = |c: char| c.is_alphanumeric() || "_-[]{}\\^|`".contains(c);
|
||||
let lower_body = body.to_lowercase();
|
||||
keywords.iter().any(|keyword| {
|
||||
let keyword = keyword.trim().to_lowercase();
|
||||
if keyword.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut at = 0;
|
||||
while let Some(hit) = lower_body[at..].find(&keyword) {
|
||||
let start = at + hit;
|
||||
let end = start + keyword.len();
|
||||
let before = lower_body[..start].chars().next_back();
|
||||
let after = lower_body[end..].chars().next();
|
||||
if !before.is_some_and(joined) && !after.is_some_and(joined) {
|
||||
return true;
|
||||
}
|
||||
// Inside a longer word. Carry on past this one rather than
|
||||
// giving up: the same word may appear again on its own.
|
||||
at = end;
|
||||
}
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// A mention of you, written the way you asked to be called.
|
||||
///
|
||||
/// The rename is a local one - no service is told about it - so a message
|
||||
@@ -846,6 +888,10 @@ impl Runtime {
|
||||
if let Some(nick) = self.irc_current_nick(&account.id).or_else(|| self.own_identity(&account.id)) {
|
||||
account.current_nick = nick;
|
||||
}
|
||||
// This account's own words, not the global ones: the field this
|
||||
// fills edits one account's list, and showing everybody's words
|
||||
// in it would mean saving them onto this account as well.
|
||||
account.highlight_keywords = state.highlights.account(&account.id);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -3028,7 +3074,11 @@ impl Runtime {
|
||||
// addressed. Only where the service acts on the word: "@everyone"
|
||||
// on IRC is somebody typing a phrase, and highlighting it would be
|
||||
// this client inventing a mention the network does not have.
|
||||
|| (from != own_nick && mentions_the_room(model::service_of(account_id), body));
|
||||
|| (from != own_nick && mentions_the_room(model::service_of(account_id), body))
|
||||
// And the words somebody asked to be told about, which is the
|
||||
// same question asked about a different list - your name is
|
||||
// simply the one word everybody has.
|
||||
|| (from != own_nick && matches_keyword(body, &state.highlights.for_account(account_id)));
|
||||
let is_own = !own_nick.is_empty() && from == own_nick;
|
||||
// Everything above this line reads the real nick, and everything
|
||||
// below it reads what you asked to be called. The order is the whole
|
||||
@@ -3443,6 +3493,65 @@ mod invite_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod keyword_tests {
|
||||
use super::matches_keyword;
|
||||
|
||||
fn words(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|w| w.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_on_its_own_counts() {
|
||||
assert!(matches_keyword("has anyone tried moho yet", &words(&["moho"])));
|
||||
assert!(matches_keyword("moho", &words(&["moho"])));
|
||||
assert!(matches_keyword("what about moho?", &words(&["moho"])));
|
||||
}
|
||||
|
||||
/// The rule that decides whether this is usable at all: a keyword that
|
||||
/// matched inside longer words would light up half a busy channel.
|
||||
#[test]
|
||||
fn a_word_inside_another_word_does_not() {
|
||||
assert!(!matches_keyword("the catalogue is out", &words(&["cat"])));
|
||||
assert!(!matches_keyword("mohogany table", &words(&["moho"])));
|
||||
assert!(!matches_keyword("some_word", &words(&["some"])));
|
||||
}
|
||||
|
||||
/// And the same word said properly later in the message still counts.
|
||||
#[test]
|
||||
fn one_bad_match_does_not_hide_a_good_one() {
|
||||
assert!(matches_keyword("the catalogue, and also the cat", &words(&["cat"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_is_not_part_of_the_question() {
|
||||
assert!(matches_keyword("MOHO shipped", &words(&["moho"])));
|
||||
assert!(matches_keyword("moho shipped", &words(&["MoHo"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyword_can_be_a_phrase() {
|
||||
assert!(matches_keyword("is it release day yet", &words(&["release day"])));
|
||||
assert!(!matches_keyword("release something", &words(&["release day"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_asked_for_never_matches() {
|
||||
assert!(!matches_keyword("anything at all", &[]));
|
||||
assert!(!matches_keyword("anything at all", &words(&["", " "])));
|
||||
assert!(!matches_keyword("", &words(&["moho"])));
|
||||
}
|
||||
|
||||
/// Punctuation is not part of a word, so a keyword still counts when it
|
||||
/// is the thing being asked about.
|
||||
#[test]
|
||||
fn punctuation_around_a_word_is_not_part_of_it() {
|
||||
assert!(matches_keyword("(moho)", &words(&["moho"])));
|
||||
assert!(matches_keyword("moho: any news", &words(&["moho"])));
|
||||
assert!(matches_keyword("@moho", &words(&["moho"])));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rename_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -31,4 +31,8 @@ pub struct AppState {
|
||||
/// so a deliberate mute is still in force after a restart.
|
||||
pub voice_prefs: Arc<crate::backend::audio::VoicePrefsStore>,
|
||||
pub dcc_prefs: Arc<crate::backend::irc_dcc::DccPrefsStore>,
|
||||
/// The words that make a message worth being told about, besides your
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user