Skip to main content

obby_client/
monitor.rs

1//! Watching whether people are online.
2//!
3//! MONITOR asks the server to tell us when a nick comes or goes, instead of us polling with ISON.
4//! The list has a server-set limit, and asking past it is refused for the whole request rather than
5//! partially, so the limit has to be respected on our side.
6
7use alloc::collections::BTreeSet;
8use alloc::string::{String, ToString};
9use alloc::vec::Vec;
10use obby_proto::CaseFolded;
11
12/// Who we are watching, and whether each is online.
13#[derive(Debug, Clone, Default)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
16#[cfg_attr(feature = "ts", ts(rename = "WatchList"))]
17pub struct WatchList {
18    watching: BTreeSet<CaseFolded>,
19    online: BTreeSet<CaseFolded>,
20}
21
22impl WatchList {
23    /// Watch nobody.
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Note that we asked to watch these.
29    pub fn watch(&mut self, folded: impl IntoIterator<Item = CaseFolded>) {
30        self.watching.extend(folded);
31    }
32
33    /// Note that we asked to stop watching these.
34    pub fn unwatch(&mut self, folded: &[CaseFolded]) {
35        for nick in folded {
36            self.watching.remove(nick);
37            self.online.remove(nick);
38        }
39    }
40
41    /// Forget everyone, which is what `MONITOR C` does.
42    pub fn clear(&mut self) {
43        self.watching.clear();
44        self.online.clear();
45    }
46
47    /// Record that someone is online.
48    pub fn mark_online(&mut self, folded: CaseFolded) {
49        self.watching.insert(folded.clone());
50        self.online.insert(folded);
51    }
52
53    /// Record that someone is offline.
54    pub fn mark_offline(&mut self, folded: &CaseFolded) {
55        self.online.remove(folded);
56    }
57
58    /// True when we are watching this nick.
59    pub fn is_watching(&self, folded: &CaseFolded) -> bool {
60        self.watching.contains(folded)
61    }
62
63    /// True when this nick is online, as far as the server has told us.
64    pub fn is_online(&self, folded: &CaseFolded) -> bool {
65        self.online.contains(folded)
66    }
67
68    /// Everyone we are watching, so a reconnect can ask for them again.
69    pub fn watched(&self) -> impl Iterator<Item = &CaseFolded> {
70        self.watching.iter()
71    }
72
73    /// How many we are watching.
74    pub fn len(&self) -> usize {
75        self.watching.len()
76    }
77
78    /// True when we are watching nobody.
79    pub fn is_empty(&self) -> bool {
80        self.watching.is_empty()
81    }
82
83    /// Nobody is online until the server says so, which is what a dropped link resets us to.
84    pub fn forget_presence(&mut self) {
85        self.online.clear();
86    }
87}
88
89/// Split a list of targets into requests that each stay inside the server's limit.
90///
91/// A `MONITOR +` naming more than `limit` targets is refused whole, so a caller with a long list has
92/// to send several requests rather than one and hope.
93pub(crate) fn batched(targets: &[String], limit: usize) -> Vec<String> {
94    let per_request = limit.max(1);
95    targets
96        .chunks(per_request)
97        .map(|chunk| chunk.join(","))
98        .collect()
99}
100
101/// Read the targets out of a comma-separated MONITOR parameter.
102pub(crate) fn split_targets(list: &str) -> Vec<String> {
103    list.split(',')
104        .filter(|target| !target.is_empty())
105        .map(ToString::to_string)
106        .collect()
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use obby_proto::Casemapping;
113
114    fn fold(nick: &str) -> CaseFolded {
115        Casemapping::Rfc1459.fold(nick)
116    }
117
118    #[test]
119    fn watching_someone_says_nothing_about_whether_they_are_here() {
120        let mut monitor = WatchList::new();
121        monitor.watch([fold("alice")]);
122        assert!(monitor.is_watching(&fold("alice")));
123        assert!(
124            !monitor.is_online(&fold("alice")),
125            "we have not heard yet, and guessing online would show a false green dot"
126        );
127    }
128
129    #[test]
130    fn the_server_deciding_someone_is_online_also_means_we_watch_them() {
131        let mut monitor = WatchList::new();
132        monitor.mark_online(fold("alice"));
133        assert!(monitor.is_watching(&fold("alice")));
134        assert!(monitor.is_online(&fold("alice")));
135
136        monitor.mark_offline(&fold("alice"));
137        assert!(
138            monitor.is_watching(&fold("alice")),
139            "going offline is not the same as being dropped"
140        );
141        assert!(!monitor.is_online(&fold("alice")));
142    }
143
144    #[test]
145    fn dropping_someone_forgets_both_facts() {
146        let mut monitor = WatchList::new();
147        monitor.mark_online(fold("alice"));
148        monitor.unwatch(&[fold("alice")]);
149        assert!(!monitor.is_watching(&fold("alice")));
150        assert!(!monitor.is_online(&fold("alice")));
151    }
152
153    #[test]
154    fn folding_means_one_person_however_they_are_spelled() {
155        let mut monitor = WatchList::new();
156        monitor.mark_online(fold("[alice]"));
157        assert!(
158            monitor.is_online(&fold("{ALICE}")),
159            "rfc1459 folds braces onto brackets, so this is the same person"
160        );
161        assert_eq!(monitor.len(), 1);
162    }
163
164    #[test]
165    fn a_dropped_link_forgets_who_was_here_but_not_who_we_watch() {
166        let mut monitor = WatchList::new();
167        monitor.mark_online(fold("alice"));
168        monitor.forget_presence();
169        assert!(monitor.is_watching(&fold("alice")));
170        assert!(
171            !monitor.is_online(&fold("alice")),
172            "presence from the old link says nothing about the new one"
173        );
174    }
175
176    #[test]
177    fn a_long_list_is_split_to_stay_inside_the_servers_limit() {
178        let targets: Vec<String> = (0..7).map(|i| alloc::format!("nick{i}")).collect();
179        let requests = batched(&targets, 3);
180        assert_eq!(
181            requests,
182            [
183                "nick0,nick1,nick2".to_string(),
184                "nick3,nick4,nick5".to_string(),
185                "nick6".to_string()
186            ],
187            "one oversized request is refused whole, so it has to be several"
188        );
189    }
190
191    #[test]
192    fn a_limit_of_zero_still_makes_progress() {
193        let targets = alloc::vec!["alice".to_string()];
194        assert_eq!(batched(&targets, 0), ["alice".to_string()]);
195    }
196
197    #[test]
198    fn reading_a_target_list_ignores_empty_entries() {
199        assert_eq!(
200            split_targets("alice,,bob,"),
201            ["alice".to_string(), "bob".to_string()]
202        );
203        assert!(split_targets("").is_empty());
204    }
205}