Skip to main content

obby_client/
model.rs

1//! The client model.
2//!
3//! Everything the connection knows: who we are, the channels we are in and who is in them, the
4//! private conversations, and the messages. An app renders this and keeps no second copy.
5
6use alloc::collections::{BTreeMap, btree_map};
7use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use obby_proto::{CaseFolded, Casemapping, Prefix};
10
11/// How many messages one channel or conversation keeps before the oldest are dropped.
12///
13/// The cap applies the same way to live traffic and to history backfill. The reference client trims
14/// only when merging history, so how much scrollback a channel holds depends on whether it ever ran
15/// a history request.
16pub const DEFAULT_RETENTION: usize = 5000;
17
18/// How many `WHOIS` records the model holds at once.
19///
20/// A host looks someone up, reads the card and moves on, so a handful are live at any moment. The
21/// ceiling exists because the numerics that fill them name whatever nick the server chooses.
22const MAX_WHOIS_RECORDS: usize = 64;
23
24/// Where a message is ordered and how it is found again.
25///
26/// Ordering is by the server's timestamp, with a monotonic sequence number breaking ties. A tie
27/// broken by arrival order alone is what the reference client relies on, and it only holds there
28/// because of an incidental property of the sort it uses.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
32pub struct MessageKey {
33    /// Milliseconds since the epoch, from `server-time` when the server sent one.
34    #[cfg_attr(feature = "ts", ts(type = "number"))]
35    pub time_ms: u64,
36    /// Assigned in arrival order, unique for the life of the connection.
37    #[cfg_attr(feature = "ts", ts(type = "number"))]
38    pub seq: u64,
39}
40
41/// What kind of thing happened.
42#[derive(Debug, Clone, PartialEq, Eq)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
45#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "snake_case"))]
46#[non_exhaustive]
47pub enum MessageKind {
48    /// An ordinary message.
49    Privmsg,
50    /// A notice, which by convention a client must not auto-reply to.
51    Notice,
52    /// A CTCP, carrying the command that was requested.
53    Ctcp {
54        /// The CTCP command, uppercased, such as `ACTION`.
55        command: String,
56    },
57    /// A bodiless message that exists only to carry tags.
58    Tagmsg,
59    /// Someone joined.
60    Join,
61    /// Someone left the channel.
62    Part,
63    /// Someone left the network.
64    Quit,
65    /// Someone was removed by an operator.
66    Kick {
67        /// Who was removed.
68        target: String,
69    },
70    /// Someone changed nick.
71    Nick {
72        /// What they changed it to.
73        new_nick: String,
74    },
75    /// The topic changed.
76    Topic,
77    /// Modes changed.
78    Mode,
79}
80
81/// One message, as the model holds it.
82#[derive(Debug, Clone, PartialEq, Eq)]
83#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
84#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
85#[cfg_attr(feature = "ts", ts(rename = "ChatMessage"))]
86pub struct ChatMessage {
87    /// Where it sits in the log.
88    pub key: MessageKey,
89    /// The network-unique id, when the server assigned one.
90    pub msgid: Option<String>,
91    /// Who sent it, as they spell their own nick.
92    pub sender: String,
93    /// What kind of event it is.
94    pub kind: MessageKind,
95    /// The body, empty for events that have none.
96    pub text: String,
97    /// The account the sender was logged in as, from `account-tag`.
98    pub account: Option<String>,
99    /// True when this arrived inside a history batch rather than live.
100    pub historical: bool,
101    /// True when this is our own message coming back through `echo-message`.
102    pub own: bool,
103    /// The message this one replies to, from the `+reply` tag.
104    ///
105    /// The server is inconsistent about the spelling and sends `+reply` from one module and
106    /// `+draft/reply` from another, so both are accepted on the way in.
107    pub reply_to: Option<String>,
108    /// Reactions, keyed by the emoji, holding who reacted.
109    pub reactions: BTreeMap<String, Vec<String>>,
110    /// A preview of the first link in this message, when the server built one.
111    #[cfg(feature = "obby")]
112    pub link_preview: Option<crate::extensions::LinkPreview>,
113    /// True once the message was redacted. The original is kept, because throwing it away leaves no
114    /// way to show who redacted what.
115    pub redacted: bool,
116}
117
118impl ChatMessage {
119    /// A message with only what every kind carries.
120    pub fn new(key: MessageKey, sender: impl Into<String>, kind: MessageKind) -> Self {
121        Self {
122            key,
123            msgid: None,
124            sender: sender.into(),
125            kind,
126            text: String::new(),
127            account: None,
128            historical: false,
129            own: false,
130            reply_to: None,
131            reactions: BTreeMap::new(),
132            #[cfg(feature = "obby")]
133            link_preview: None,
134            redacted: false,
135        }
136    }
137}
138
139/// The messages of one channel or conversation, ordered and bounded.
140#[derive(Debug, Clone)]
141#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
142#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
143#[cfg_attr(feature = "ts", ts(rename = "MessageLog"))]
144pub struct MessageLog {
145    #[cfg_attr(feature = "serde", serde(with = "messages_as_list"))]
146    #[cfg_attr(feature = "ts", ts(as = "Vec<ChatMessage>"))]
147    messages: BTreeMap<MessageKey, ChatMessage>,
148    by_msgid: BTreeMap<String, MessageKey>,
149    retention: usize,
150}
151
152/// Carry the message log as a list rather than a map.
153///
154/// The map is keyed by a [`MessageKey`], and JSON has no way to spell a structured object key, so
155/// every binding that serialises the model would otherwise fail on the first channel that has said
156/// anything. Each message already carries its own key, so the map rebuilds from the list exactly.
157#[cfg(feature = "serde")]
158mod messages_as_list {
159    use super::{BTreeMap, ChatMessage, MessageKey};
160    use alloc::vec::Vec;
161    use serde::{Deserialize, Deserializer, Serialize, Serializer};
162
163    pub(super) fn serialize<S: Serializer>(
164        messages: &BTreeMap<MessageKey, ChatMessage>,
165        serializer: S,
166    ) -> Result<S::Ok, S::Error> {
167        messages.values().collect::<Vec<_>>().serialize(serializer)
168    }
169
170    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
171        deserializer: D,
172    ) -> Result<BTreeMap<MessageKey, ChatMessage>, D::Error> {
173        Ok(Vec::<ChatMessage>::deserialize(deserializer)?
174            .into_iter()
175            .map(|message| (message.key, message))
176            .collect())
177    }
178}
179
180impl Default for MessageLog {
181    fn default() -> Self {
182        Self::with_retention(DEFAULT_RETENTION)
183    }
184}
185
186impl MessageLog {
187    /// An empty log holding at most this many messages.
188    pub fn with_retention(retention: usize) -> Self {
189        Self {
190            messages: BTreeMap::new(),
191            by_msgid: BTreeMap::new(),
192            retention: retention.max(1),
193        }
194    }
195
196    /// Insert a message unless we already have it, returning whether it was new.
197    ///
198    /// A message with an id is deduplicated by that id. One without is compared against the
199    /// messages sharing its exact timestamp, which is enough to catch a replay while staying
200    /// bounded: an unbounded content scan would be the only alternative.
201    pub fn insert(&mut self, message: ChatMessage) -> bool {
202        if let Some(msgid) = &message.msgid {
203            if self.by_msgid.contains_key(msgid) {
204                return false;
205            }
206        } else if self.has_twin(&message) {
207            return false;
208        }
209        if let Some(msgid) = &message.msgid {
210            self.by_msgid.insert(msgid.clone(), message.key);
211        }
212        self.messages.insert(message.key, message);
213        self.trim();
214        true
215    }
216
217    fn has_twin(&self, candidate: &ChatMessage) -> bool {
218        let same_instant = MessageKey {
219            time_ms: candidate.key.time_ms,
220            seq: 0,
221        }..MessageKey {
222            time_ms: candidate.key.time_ms.saturating_add(1),
223            seq: 0,
224        };
225        self.messages.range(same_instant).any(|(_, held)| {
226            held.sender == candidate.sender
227                && held.text == candidate.text
228                && held.kind == candidate.kind
229        })
230    }
231
232    fn trim(&mut self) {
233        while self.messages.len() > self.retention {
234            let Some((_, dropped)) = self.messages.pop_first() else {
235                return;
236            };
237            if let Some(msgid) = dropped.msgid {
238                self.by_msgid.remove(&msgid);
239            }
240        }
241    }
242
243    /// True when a message with this id is already held.
244    pub fn contains(&self, msgid: &str) -> bool {
245        self.by_msgid.contains_key(msgid)
246    }
247
248    /// Look one up by its network id.
249    pub fn get(&self, msgid: &str) -> Option<&ChatMessage> {
250        self.messages.get(self.by_msgid.get(msgid)?)
251    }
252
253    /// Borrow one mutably by its network id, to attach a reaction or mark it redacted.
254    pub fn get_mut(&mut self, msgid: &str) -> Option<&mut ChatMessage> {
255        let key = *self.by_msgid.get(msgid)?;
256        self.messages.get_mut(&key)
257    }
258
259    /// Look one up by where it sits in this log.
260    ///
261    /// `Change::MessageAdded` reports a key rather than the message, so this is how a host turns
262    /// that notification into the message it names.
263    pub fn get_by_key(&self, key: &MessageKey) -> Option<&ChatMessage> {
264        self.messages.get(key)
265    }
266
267    /// Every message, oldest first.
268    pub fn iter(&self) -> btree_map::Values<'_, MessageKey, ChatMessage> {
269        self.messages.values()
270    }
271
272    /// The most recent message.
273    pub fn last(&self) -> Option<&ChatMessage> {
274        self.messages.last_key_value().map(|(_, message)| message)
275    }
276
277    /// The oldest message held, which is where a history request should resume from.
278    pub fn first(&self) -> Option<&ChatMessage> {
279        self.messages.first_key_value().map(|(_, message)| message)
280    }
281
282    /// How many are held.
283    pub fn len(&self) -> usize {
284        self.messages.len()
285    }
286
287    /// True when nothing is held.
288    pub fn is_empty(&self) -> bool {
289        self.messages.is_empty()
290    }
291}
292
293impl<'a> IntoIterator for &'a MessageLog {
294    type Item = &'a ChatMessage;
295    type IntoIter = btree_map::Values<'a, MessageKey, ChatMessage>;
296
297    fn into_iter(self) -> Self::IntoIter {
298        self.iter()
299    }
300}
301
302/// What one member holds in one channel.
303///
304/// Only the channel-specific part. Who they are, what account they hold and whether they are away
305/// are the same everywhere, so they live once on [`Person`] rather than being copied into every
306/// channel they are in and drifting apart.
307#[derive(Debug, Clone, Default, PartialEq, Eq)]
308#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
309#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
310pub struct Membership {
311    /// The prefix characters they hold here, highest rank first.
312    pub prefixes: String,
313}
314
315impl Membership {
316    /// The highest prefix they hold, which is what a compact member list shows.
317    pub fn top_prefix(&self) -> Option<char> {
318        self.prefixes.chars().next()
319    }
320
321    /// Add a prefix, keeping the list in the server's rank order.
322    pub fn grant(&mut self, prefix: char, order: &Prefix) {
323        if self.prefixes.contains(prefix) {
324            return;
325        }
326        self.prefixes.push(prefix);
327        let mut ranked: Vec<char> = self.prefixes.chars().collect();
328        ranked.sort_by_key(|c| order.rank(*c).unwrap_or(usize::MAX));
329        self.prefixes = ranked.into_iter().collect();
330    }
331
332    /// Remove a prefix.
333    pub fn revoke(&mut self, prefix: char) {
334        self.prefixes.retain(|c| c != prefix);
335    }
336}
337
338/// Someone we know about, held once however many channels we share.
339#[derive(Debug, Clone, Default, PartialEq, Eq)]
340#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
341#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
342pub struct Person {
343    /// Their nick, as they spell it.
344    pub nick: String,
345    /// The account they are logged in as.
346    pub account: Option<String>,
347    /// Their away message, when they are away.
348    pub away: Option<String>,
349    /// True when the server marks them as a bot.
350    pub bot: bool,
351    /// Their username, from WHO or a hostmask.
352    pub username: Option<String>,
353    /// Their host, from WHO or a hostmask.
354    pub host: Option<String>,
355    /// Their realname, from WHO.
356    pub realname: Option<String>,
357    /// True when the server marks them as an operator.
358    pub operator: bool,
359    /// Metadata the server holds, such as `display-name`, `color` and `avatar`.
360    ///
361    /// These are plain `draft/metadata-2` keys with no vendor prefix, despite everything else Obby
362    /// adds being namespaced.
363    pub metadata: BTreeMap<String, String>,
364}
365
366/// What a `WHOIS` said about someone.
367///
368/// A reply is nine numerics that arrive one at a time, so they are collected here and reported once,
369/// when the closing `318` lands. A host that reacted to each numeric would redraw a profile card
370/// nine times and show eight incomplete ones.
371#[derive(Debug, Clone, Default, PartialEq, Eq)]
372#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
373#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
374pub struct Whois {
375    /// Their nick, as the server spells it.
376    pub nick: String,
377    /// Their username, from `311`.
378    pub username: Option<String>,
379    /// Their host, from `311`.
380    pub host: Option<String>,
381    /// Their realname, from `311`.
382    pub realname: Option<String>,
383    /// The server they are on, from `312`.
384    pub server: Option<String>,
385    /// What that server calls itself, from `312`.
386    pub server_info: Option<String>,
387    /// How the server describes their operator privileges, from `313`, when they have any.
388    pub operator: Option<String>,
389    /// How long they have been idle, from `317`.
390    #[cfg_attr(feature = "ts", ts(type = "number | null"))]
391    pub idle_secs: Option<u64>,
392    /// When they connected, in milliseconds since the Unix epoch, from `317`.
393    #[cfg_attr(feature = "ts", ts(type = "number | null"))]
394    pub signon_ms: Option<u64>,
395    /// The channels they are in, keeping the prefix each one carries, from `319`.
396    pub channels: Vec<String>,
397    /// The account they are logged in as, from `330`.
398    pub account: Option<String>,
399    /// Where they are connecting from, as the server words it, from `338` or `378`.
400    pub actual_host: Option<String>,
401    /// True when the server said the connection is over TLS, from `671`.
402    pub secure: bool,
403    /// True once the closing `318` arrived and there is nothing more to come.
404    pub complete: bool,
405}
406
407/// A channel we are in.
408#[derive(Debug, Clone, Default)]
409#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
410#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
411pub struct Channel {
412    /// The name as the server spells it.
413    pub name: String,
414    /// The topic, when one is set.
415    pub topic: Option<String>,
416    /// Who set the topic and when.
417    pub topic_by: Option<String>,
418    /// Channel modes currently set, with their arguments.
419    pub modes: BTreeMap<char, Option<String>>,
420    /// Who is in it.
421    pub members: BTreeMap<CaseFolded, Membership>,
422    /// What was said.
423    pub log: MessageLog,
424    /// Messages since the last read marker.
425    pub unread: u32,
426    /// Unread messages that mention us.
427    pub mentions: u32,
428    /// Metadata the server holds, such as `display-name`, `color`, `avatar` and `bot`.
429    ///
430    /// These are plain `draft/metadata-2` keys with no vendor prefix, despite everything else Obby
431    /// adds being namespaced.
432    pub metadata: BTreeMap<String, String>,
433    /// The read marker timestamp the server last confirmed.
434    pub read_marker: Option<String>,
435    /// Who is composing a message here right now.
436    pub typing: alloc::collections::BTreeSet<CaseFolded>,
437    /// Modes by their name rather than their letter, from `draft/named-modes`.
438    ///
439    /// A letter means nothing without the server telling you what it does, and two servers spell
440    /// the same feature differently. The names are stable, so this is what a user interface should
441    /// show and what a setting should be keyed by.
442    pub named_modes: BTreeMap<String, Option<String>>,
443}
444
445/// A private conversation with one other person.
446#[derive(Debug, Clone, Default)]
447#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
448#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
449#[cfg_attr(feature = "ts", ts(rename = "Conversation"))]
450pub struct Conversation {
451    /// Their nick, as they spell it.
452    pub nick: String,
453    /// What was said.
454    pub log: MessageLog,
455    /// Messages since the last read marker.
456    pub unread: u32,
457    /// The read marker timestamp the server last confirmed.
458    pub read_marker: Option<String>,
459    /// Who is composing a message here right now.
460    pub typing: alloc::collections::BTreeSet<CaseFolded>,
461}
462
463/// Who we are on this connection.
464#[derive(Debug, Clone, Default)]
465#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
466#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
467#[cfg_attr(feature = "ts", ts(rename = "LocalUser"))]
468pub struct LocalUser {
469    /// Our current nick.
470    pub nick: String,
471    /// The account we authenticated as.
472    pub account: Option<String>,
473    /// Our user modes.
474    pub modes: String,
475    /// Metadata the server holds, such as `display-name`, `color`, `avatar` and `bot`.
476    ///
477    /// These are plain `draft/metadata-2` keys with no vendor prefix, despite everything else Obby
478    /// adds being namespaced.
479    pub metadata: BTreeMap<String, String>,
480    /// Our away message, when we are away.
481    pub away: Option<String>,
482}
483
484/// Everything the connection knows.
485#[derive(Debug, Clone)]
486#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
487#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
488pub struct Model {
489    /// Who we are.
490    pub me: LocalUser,
491    channels: BTreeMap<CaseFolded, Channel>,
492    conversations: BTreeMap<CaseFolded, Conversation>,
493    people: BTreeMap<CaseFolded, Person>,
494    whois: BTreeMap<CaseFolded, Whois>,
495    retention: usize,
496    #[cfg_attr(feature = "ts", ts(type = "number"))]
497    next_seq: u64,
498}
499
500impl Default for Model {
501    fn default() -> Self {
502        Self::with_retention(DEFAULT_RETENTION)
503    }
504}
505
506impl Model {
507    /// An empty model with this retention limit per target.
508    pub fn with_retention(retention: usize) -> Self {
509        Self {
510            me: LocalUser::default(),
511            channels: BTreeMap::new(),
512            conversations: BTreeMap::new(),
513            people: BTreeMap::new(),
514            whois: BTreeMap::new(),
515            retention: retention.max(1),
516            next_seq: 0,
517        }
518    }
519
520    /// The next sequence number, which breaks timestamp ties in arrival order.
521    pub fn next_message_key(&mut self, time_ms: u64) -> MessageKey {
522        let seq = self.next_seq;
523        self.next_seq = self.next_seq.wrapping_add(1);
524        MessageKey { time_ms, seq }
525    }
526
527    /// A channel by name, folded with the server's casemapping.
528    pub fn channel(&self, key: &CaseFolded) -> Option<&Channel> {
529        self.channels.get(key)
530    }
531
532    /// A channel, created if we have not seen it.
533    pub fn channel_or_insert(&mut self, key: CaseFolded, name: &str) -> &mut Channel {
534        let retention = self.retention;
535        self.channels.entry(key).or_insert_with(|| Channel {
536            name: name.to_string(),
537            log: MessageLog::with_retention(retention),
538            ..Channel::default()
539        })
540    }
541
542    /// Every channel, for changing what is in them.
543    pub fn channels_mut(&mut self) -> btree_map::IterMut<'_, CaseFolded, Channel> {
544        self.channels.iter_mut()
545    }
546
547    /// A channel we already know, for changing what is in it.
548    pub fn channel_mut(&mut self, key: &CaseFolded) -> Option<&mut Channel> {
549        self.channels.get_mut(key)
550    }
551
552    /// Forget a channel, which is what leaving one means.
553    pub fn remove_channel(&mut self, key: &CaseFolded) -> Option<Channel> {
554        self.channels.remove(key)
555    }
556
557    /// Every channel, in folded name order.
558    pub fn channels(&self) -> btree_map::Iter<'_, CaseFolded, Channel> {
559        self.channels.iter()
560    }
561
562    /// A private conversation.
563    pub fn conversation(&self, key: &CaseFolded) -> Option<&Conversation> {
564        self.conversations.get(key)
565    }
566
567    /// A private conversation, created if we have not seen it.
568    pub fn conversation_or_insert(&mut self, key: CaseFolded, nick: &str) -> &mut Conversation {
569        let retention = self.retention;
570        self.conversations
571            .entry(key)
572            .or_insert_with(|| Conversation {
573                nick: nick.to_string(),
574                log: MessageLog::with_retention(retention),
575                ..Conversation::default()
576            })
577    }
578
579    /// Record that someone started or stopped composing a message somewhere.
580    ///
581    /// Returns true when this changed anything, so a caller does not report a state that already
582    /// held. A typing indicator repeats on a timer, and re-reporting each repeat makes it flicker.
583    pub fn set_typing(&mut self, target: &CaseFolded, who: CaseFolded, typing: bool) -> bool {
584        let set = match self.channels.get_mut(target) {
585            Some(channel) => &mut channel.typing,
586            None => match self.conversations.get_mut(target) {
587                Some(query) => &mut query.typing,
588                None => return false,
589            },
590        };
591        if typing {
592            set.insert(who)
593        } else {
594            set.remove(&who)
595        }
596    }
597
598    /// Someone we know about.
599    pub fn person(&self, key: &CaseFolded) -> Option<&Person> {
600        self.people.get(key)
601    }
602
603    /// Someone we know about, remembered from now on if we did not already.
604    pub fn person_or_insert(&mut self, key: CaseFolded, nick: &str) -> &mut Person {
605        self.people.entry(key).or_insert_with(|| Person {
606            nick: nick.to_string(),
607            ..Person::default()
608        })
609    }
610
611    /// Someone we already know, for changing what we hold about them.
612    pub fn person_mut(&mut self, key: &CaseFolded) -> Option<&mut Person> {
613        self.people.get_mut(key)
614    }
615
616    /// What the last `WHOIS` said about someone.
617    pub fn whois(&self, key: &CaseFolded) -> Option<&Whois> {
618        self.whois.get(key)
619    }
620
621    /// The record a `WHOIS` reply is filling in.
622    ///
623    /// A record whose `318` already arrived is replaced rather than added to, because the numerics a
624    /// second reply leaves out are the ones that stopped being true: an operator who deopered sends
625    /// no `313` to say so.
626    pub fn whois_or_insert(&mut self, key: CaseFolded, nick: &str) -> &mut Whois {
627        // a server decides how many nicks it names in these numerics, so without a ceiling it
628        // decides how much memory we hold; the oldest complete record goes first
629        if self.whois.len() >= MAX_WHOIS_RECORDS && !self.whois.contains_key(&key) {
630            let stale = self
631                .whois
632                .iter()
633                .find(|(_, record)| record.complete)
634                .or_else(|| self.whois.iter().next())
635                .map(|(key, _)| key.clone());
636            if let Some(stale) = stale {
637                self.whois.remove(&stale);
638            }
639        }
640        let record = self.whois.entry(key).or_default();
641        if record.complete || record.nick.is_empty() {
642            *record = Whois {
643                nick: nick.to_string(),
644                ..Whois::default()
645            };
646        }
647        record
648    }
649
650    /// Every `WHOIS` record held, in folded nick order.
651    pub fn whois_records(&self) -> btree_map::Iter<'_, CaseFolded, Whois> {
652        self.whois.iter()
653    }
654
655    /// A `WHOIS` record we already hold, for filling in as its numerics arrive.
656    pub fn whois_mut(&mut self, key: &CaseFolded) -> Option<&mut Whois> {
657        self.whois.get_mut(key)
658    }
659
660    /// A private conversation we already know, for changing what is in it.
661    pub fn conversation_mut(&mut self, key: &CaseFolded) -> Option<&mut Conversation> {
662        self.conversations.get_mut(key)
663    }
664
665    /// Every private conversation.
666    pub fn conversations(&self) -> btree_map::Iter<'_, CaseFolded, Conversation> {
667        self.conversations.iter()
668    }
669
670    /// Rename someone everywhere they appear, which is what a NICK means.
671    ///
672    /// Membership is keyed by the folded nick, so a rename has to move every entry rather than edit
673    /// one field, and a fold that only lowercases ASCII would leave duplicates behind on any server
674    /// that folds the bracket alphabet.
675    pub fn rename(&mut self, casemapping: Casemapping, from: &str, to: &str) {
676        let (old, new) = (casemapping.fold(from), casemapping.fold(to));
677        for channel in self.channels.values_mut() {
678            if let Some(membership) = channel.members.remove(&old) {
679                channel.members.insert(new.clone(), membership);
680            }
681        }
682        if let Some(mut query) = self.conversations.remove(&old) {
683            query.nick = to.to_string();
684            self.conversations.insert(new.clone(), query);
685        }
686        if let Some(mut person) = self.people.remove(&old) {
687            person.nick = to.to_string();
688            self.people.insert(new, person);
689        }
690        if casemapping.eq(&self.me.nick, from) {
691            self.me.nick = to.to_string();
692        }
693    }
694
695    /// Move a channel to a new name, keeping everything in it. Returns whether we were in it.
696    ///
697    /// The map is keyed by the folded name, so a rename has to move the entry: editing the name in
698    /// place leaves every later lookup of the new name missing a channel we are still sitting in.
699    pub fn rename_channel(&mut self, casemapping: Casemapping, from: &str, to: &str) -> bool {
700        let Some(mut channel) = self.channels.remove(&casemapping.fold(from)) else {
701            return false;
702        };
703        channel.name = to.to_string();
704        self.channels.insert(casemapping.fold(to), channel);
705        true
706    }
707
708    /// Forget anyone we no longer share a channel or a conversation with.
709    ///
710    /// Without this a long session accumulates one permanent record per nick it has ever seen,
711    /// which a busy channel supplies for free.
712    pub fn forget_strangers(&mut self) {
713        let mut keep: alloc::collections::BTreeSet<CaseFolded> =
714            self.conversations.keys().cloned().collect();
715        for channel in self.channels.values() {
716            keep.extend(channel.members.keys().cloned());
717        }
718        let me = self.me.nick.clone();
719        self.people
720            .retain(|key, person| keep.contains(key) || person.nick == me);
721        self.whois.retain(|key, _| keep.contains(key));
722    }
723
724    /// Forget every WHOIS record.
725    ///
726    /// A record describes someone as the server saw them on one connection. A reply cut short by a
727    /// dead link would otherwise leave fields behind for the next connection to merge into.
728    pub fn forget_whois(&mut self) {
729        self.whois.clear();
730    }
731
732    /// Remove someone from every channel, which is what a QUIT means. Returns where they were.
733    pub fn remove_everywhere(&mut self, who: &CaseFolded) -> Vec<CaseFolded> {
734        let mut left = Vec::new();
735        for (key, channel) in &mut self.channels {
736            if channel.members.remove(who).is_some() {
737                left.push(key.clone());
738            }
739        }
740        left
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    fn message(seq: u64, time_ms: u64, sender: &str, text: &str) -> ChatMessage {
749        let mut message =
750            ChatMessage::new(MessageKey { time_ms, seq }, sender, MessageKind::Privmsg);
751        message.text = text.to_string();
752        message
753    }
754
755    fn with_id(mut message: ChatMessage, msgid: &str) -> ChatMessage {
756        message.msgid = Some(msgid.to_string());
757        message
758    }
759
760    #[test]
761    fn orders_by_timestamp_not_by_arrival() {
762        let mut log = MessageLog::default();
763        assert!(log.insert(message(0, 200, "a", "second")));
764        assert!(log.insert(message(1, 100, "a", "first")));
765        let texts: Vec<&str> = log.iter().map(|m| m.text.as_str()).collect();
766        assert_eq!(
767            texts,
768            ["first", "second"],
769            "a backfilled message sorts into place"
770        );
771    }
772
773    #[test]
774    fn breaks_a_timestamp_tie_by_arrival() {
775        let mut log = MessageLog::default();
776        log.insert(message(0, 100, "a", "one"));
777        log.insert(message(1, 100, "a", "two"));
778        log.insert(message(2, 100, "a", "three"));
779        let texts: Vec<&str> = log.iter().map(|m| m.text.as_str()).collect();
780        assert_eq!(texts, ["one", "two", "three"]);
781    }
782
783    #[test]
784    fn refuses_a_message_it_already_has_by_id() {
785        let mut log = MessageLog::default();
786        assert!(log.insert(with_id(message(0, 100, "a", "hi"), "x1")));
787        assert!(
788            !log.insert(with_id(message(1, 100, "a", "hi"), "x1")),
789            "a bouncer replaying the same msgid must not double up"
790        );
791        assert_eq!(log.len(), 1);
792    }
793
794    #[test]
795    fn refuses_an_identical_message_with_no_id() {
796        let mut log = MessageLog::default();
797        assert!(log.insert(message(0, 100, "a", "hi")));
798        assert!(!log.insert(message(1, 100, "a", "hi")));
799        assert_eq!(log.len(), 1);
800    }
801
802    #[test]
803    fn keeps_the_same_text_at_a_different_instant() {
804        let mut log = MessageLog::default();
805        assert!(log.insert(message(0, 100, "a", "hi")));
806        assert!(
807            log.insert(message(1, 101, "a", "hi")),
808            "saying it twice is not a duplicate"
809        );
810        assert_eq!(log.len(), 2);
811    }
812
813    #[test]
814    fn keeps_the_same_text_from_a_different_sender() {
815        let mut log = MessageLog::default();
816        assert!(log.insert(message(0, 100, "a", "hi")));
817        assert!(log.insert(message(1, 100, "b", "hi")));
818        assert_eq!(log.len(), 2);
819    }
820
821    #[test]
822    fn trims_the_oldest_once_it_is_full() {
823        let mut log = MessageLog::with_retention(3);
824        for i in 0..5 {
825            log.insert(message(i, 100 + i, "a", "x"));
826        }
827        assert_eq!(log.len(), 3);
828        assert_eq!(log.first().map(|m| m.key.time_ms), Some(102));
829        assert_eq!(log.last().map(|m| m.key.time_ms), Some(104));
830    }
831
832    #[test]
833    fn trimming_forgets_the_id_index_too() {
834        let mut log = MessageLog::with_retention(2);
835        log.insert(with_id(message(0, 100, "a", "x"), "old"));
836        log.insert(with_id(message(1, 101, "a", "x"), "mid"));
837        log.insert(with_id(message(2, 102, "a", "x"), "new"));
838        assert!(
839            !log.contains("old"),
840            "a dropped message must not linger in the index"
841        );
842        assert!(log.contains("new"));
843        assert_eq!(log.get("old"), None);
844    }
845
846    #[test]
847    fn the_cap_applies_to_backfill_the_same_way() {
848        let mut log = MessageLog::with_retention(2);
849        log.insert(message(0, 300, "a", "live"));
850        log.insert(message(1, 100, "a", "old"));
851        log.insert(message(2, 200, "a", "older"));
852        assert_eq!(log.len(), 2);
853        let texts: Vec<&str> = log.iter().map(|m| m.text.as_str()).collect();
854        assert_eq!(
855            texts,
856            ["older", "live"],
857            "the oldest goes, whichever way it arrived"
858        );
859    }
860
861    #[test]
862    fn finds_a_message_again_to_react_to_it() {
863        let mut log = MessageLog::default();
864        log.insert(with_id(message(0, 100, "a", "hi"), "x1"));
865        let found = log.get_mut("x1").expect("the message is there");
866        found
867            .reactions
868            .entry("👍".to_string())
869            .or_default()
870            .push("b".to_string());
871        assert_eq!(log.get("x1").map(|m| m.reactions.len()), Some(1));
872    }
873
874    #[test]
875    fn ranks_prefixes_in_the_servers_order_not_the_order_granted() {
876        let order = Prefix::parse("(qaohv)~&@%+").expect("valid prefix");
877        let mut member = Membership::default();
878        member.grant('+', &order);
879        member.grant('~', &order);
880        member.grant('@', &order);
881        assert_eq!(member.prefixes, "~@+");
882        assert_eq!(member.top_prefix(), Some('~'));
883        member.revoke('~');
884        assert_eq!(member.top_prefix(), Some('@'));
885    }
886
887    #[test]
888    fn granting_the_same_prefix_twice_changes_nothing() {
889        let order = Prefix::default();
890        let mut member = Membership::default();
891        member.grant('@', &order);
892        member.grant('@', &order);
893        assert_eq!(member.prefixes, "@");
894    }
895
896    #[test]
897    fn a_rename_moves_the_member_rather_than_leaving_a_twin() {
898        let map = Casemapping::Rfc1459;
899        let mut model = Model::default();
900        model
901            .channel_or_insert(map.fold("#obby"), "#obby")
902            .members
903            .insert(map.fold("[nick]"), Membership::default());
904        model.person_or_insert(map.fold("[nick]"), "[nick]");
905
906        model.rename(map, "[nick]", "{NICK}2");
907
908        let channel = model.channel(&map.fold("#obby")).expect("channel");
909        assert_eq!(
910            channel.members.len(),
911            1,
912            "folding {{}} onto [] must not leave two entries"
913        );
914        assert!(channel.members.contains_key(&map.fold("{nick}2")));
915        assert_eq!(
916            model.person(&map.fold("{nick}2")).map(|p| p.nick.as_str()),
917            Some("{NICK}2"),
918            "the person is renamed once, not once per channel"
919        );
920    }
921
922    #[test]
923    fn a_rename_of_ourselves_updates_who_we_are() {
924        let map = Casemapping::Rfc1459;
925        let mut model = Model::default();
926        model.me.nick = "me".to_string();
927        model.rename(map, "ME", "you");
928        assert_eq!(
929            model.me.nick, "you",
930            "the server may echo our nick in any case"
931        );
932    }
933
934    #[test]
935    fn a_quit_reports_every_channel_they_were_in() {
936        let map = Casemapping::Rfc1459;
937        let mut model = Model::default();
938        for name in ["#a", "#b", "#c"] {
939            model
940                .channel_or_insert(map.fold(name), name)
941                .members
942                .insert(map.fold("bob"), Membership::default());
943        }
944        model
945            .channel_or_insert(map.fold("#c"), "#c")
946            .members
947            .remove(&map.fold("bob"));
948
949        let left = model.remove_everywhere(&map.fold("bob"));
950        assert_eq!(left, [map.fold("#a"), map.fold("#b")]);
951    }
952
953    #[test]
954    fn retention_reaches_every_target_it_creates() {
955        let map = Casemapping::Rfc1459;
956        let mut model = Model::with_retention(2);
957        let channel = model.channel_or_insert(map.fold("#a"), "#a");
958        for i in 0..4 {
959            channel.log.insert(message(i, 100 + i, "a", "x"));
960        }
961        assert_eq!(channel.log.len(), 2);
962
963        let query = model.conversation_or_insert(map.fold("bob"), "bob");
964        for i in 0..4 {
965            query.log.insert(message(i, 100 + i, "bob", "x"));
966        }
967        assert_eq!(query.log.len(), 2);
968    }
969
970    #[test]
971    fn a_default_model_keeps_more_than_one_message() {
972        let map = Casemapping::Rfc1459;
973        let mut model = Model::default();
974        let channel = model.channel_or_insert(map.fold("#a"), "#a");
975        for i in 0..10 {
976            channel.log.insert(message(i, 100 + i, "a", "x"));
977        }
978        assert_eq!(channel.log.len(), 10);
979    }
980
981    #[test]
982    fn sequence_numbers_never_repeat() {
983        let mut model = Model::default();
984        let first = model.next_message_key(100);
985        let second = model.next_message_key(100);
986        assert!(second > first);
987    }
988}
989
990#[cfg(all(test, feature = "serde"))]
991mod serde_tests {
992    use super::*;
993
994    #[test]
995    fn a_log_survives_a_json_round_trip() {
996        let mut log = MessageLog::default();
997        for (seq, text) in ["first", "second"].into_iter().enumerate() {
998            let mut message = ChatMessage::new(
999                MessageKey {
1000                    time_ms: 100 + seq as u64,
1001                    seq: seq as u64,
1002                },
1003                "bob",
1004                MessageKind::Privmsg,
1005            );
1006            message.text = text.to_string();
1007            message.msgid = Some(alloc::format!("m{seq}"));
1008            log.insert(message);
1009        }
1010
1011        // JSON cannot spell a structured object key, so the log travels as a list
1012        let json = serde_json::to_string(&log).expect("a log serialises");
1013        assert!(json.contains('['), "the messages are a list, not an object");
1014
1015        let restored: MessageLog = serde_json::from_str(&json).expect("and comes back");
1016        assert_eq!(restored.len(), 2);
1017        assert_eq!(restored.get("m1").map(|m| m.text.as_str()), Some("second"));
1018        let texts: Vec<&str> = restored.iter().map(|m| m.text.as_str()).collect();
1019        assert_eq!(texts, ["first", "second"], "and keeps its order");
1020    }
1021
1022    #[test]
1023    fn a_whole_model_serialises() {
1024        let map = Casemapping::Ascii;
1025        let mut model = Model::default();
1026        let mut message = ChatMessage::new(
1027            MessageKey { time_ms: 1, seq: 0 },
1028            "bob",
1029            MessageKind::Privmsg,
1030        );
1031        message.text = "hello".to_string();
1032        model
1033            .channel_or_insert(map.fold("#obby"), "#obby")
1034            .log
1035            .insert(message);
1036        serde_json::to_string(&model).expect("the model a host reads must serialise");
1037    }
1038}