feat(protocol): away status, excluded phrases, queue-place requests

Three features the client half-had. SetStatus (28) was only ever sent as
"online" at login, so there was no way to go away; excluded search phrases
(160) were parsed and logged into nowhere, so every client kept spending
searches on queries the server was never going to answer; and a queued
download could answer a peer asking about its place but could never ask
about its own.

Each is now driven end to end: the away status seen by another user, a
refused query on a stub server that announces phrases (soulfind keeps its
filters in a database the suite cannot seed), and a peer's answer landing
on the download as its queue position.
This commit is contained in:
Michel de Graaf
2026-09-09 16:27:20 +00:00
parent 60a0f8fc91
commit 65c44cc66b
10 changed files with 429 additions and 3 deletions
+31
View File
@@ -368,6 +368,8 @@ pub enum ClientOperation {
CantConnectToPeer {
token: u32,
},
/// Phrases the server refuses to search for (code 160).
ExcludedSearchPhrases(Vec<String>),
/// A parent candidate told us how deep it sits. `link` says which dial
/// to that user is talking.
ParentBranchLevel {
@@ -469,6 +471,9 @@ pub struct ClientContext {
privileged_users: HashSet<String>,
/// Seconds of our own privileges left (code 92), once we have asked.
own_privileges: Option<u32>,
/// Phrases the server will not search for (code 160). A query carrying one
/// is refused locally rather than spent on the server.
excluded_search_phrases: Vec<String>,
/// Peers waiting for one of our upload slots.
upload_queue: Vec<QueuedUpload>,
/// Arrival counter for the queue's first-come tie-break.
@@ -575,6 +580,7 @@ impl ClientContext {
wishlist_interval: None,
privileged_users: HashSet::new(),
own_privileges: None,
excluded_search_phrases: Vec::new(),
upload_queue: Vec::new(),
upload_seq: 0,
upload_slots: DEFAULT_UPLOAD_SLOTS,
@@ -725,6 +731,31 @@ impl ClientContext {
self.user_interests.get(username).cloned()
}
/// Record the phrases the server refuses to search for (code 160).
pub fn set_excluded_search_phrases(&mut self, phrases: Vec<String>) {
self.excluded_search_phrases = phrases;
}
/// The phrases the server refuses to search for.
#[must_use]
pub fn excluded_search_phrases(&self) -> Vec<String> {
self.excluded_search_phrases.clone()
}
/// The first excluded phrase `query` contains, if any. Matching is
/// case-insensitive and on substrings, which is how the server applies its
/// own filter.
#[must_use]
pub fn excluded_phrase_in(&self, query: &str) -> Option<String> {
let haystack = query.to_lowercase();
self.excluded_search_phrases
.iter()
.find(|phrase| {
!phrase.is_empty() && haystack.contains(&phrase.to_lowercase())
})
.cloned()
}
/// Forget any interests cached for `username`, so a poll after a fresh
/// request cannot mistake the previous answer for this one.
pub fn invalidate_user_interests(&mut self, username: &str) {