1use std::collections::HashMap;
6
7use crate::i18n::{tr, trf};
8use crate::proto::{fmt_secs, note_line, ChatLine, RoomState};
9use crate::{NoteKind, Platform};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13struct Modes {
14 slow: u32,
15 followers: i32,
16 emote_only: bool,
17 subs_only: bool,
18}
19
20impl Modes {
21 fn with(self, s: RoomState) -> Modes {
23 Modes {
24 slow: s.slow.unwrap_or(self.slow),
25 followers: s.followers.unwrap_or(self.followers),
26 emote_only: s.emote_only.unwrap_or(self.emote_only),
27 subs_only: s.subs_only.unwrap_or(self.subs_only),
28 }
29 }
30}
31
32const OFF: Modes = Modes {
34 slow: 0,
35 followers: -1,
36 emote_only: false,
37 subs_only: false,
38};
39
40#[derive(Default)]
41pub struct RoomModes {
42 seen: HashMap<String, Modes>,
43}
44
45impl RoomModes {
46 pub fn new() -> Self {
47 Self::default()
48 }
49
50 pub fn update(&mut self, channel: &str, now: RoomState) -> Vec<ChatLine> {
53 let key = channel.to_lowercase();
54 let Some(was) = self.seen.get(&key).copied() else {
55 self.seen.insert(key, OFF.with(now));
56 return Vec::new();
57 };
58 let next = was.with(now);
59 self.seen.insert(key, next);
60 changes(was, next)
61 .into_iter()
62 .filter_map(|what| {
63 note_line(
64 Platform::Twitch,
65 channel.to_string(),
66 String::new(),
67 None,
68 NoteKind::Mode,
69 what,
70 String::new(),
71 )
72 })
73 .collect()
74 }
75}
76
77fn on_off(on_key: &'static str, off_key: &'static str, on: bool) -> String {
78 tr(if on { on_key } else { off_key }).to_string()
79}
80
81fn changes(was: Modes, now: Modes) -> Vec<String> {
82 let mut out = Vec::new();
83 if was.slow != now.slow {
84 out.push(if now.slow == 0 {
85 tr("room.slow_off").to_string()
86 } else {
87 trf("room.slow_on", &[("dur", &fmt_secs(now.slow as u64))])
88 });
89 }
90 if was.followers != now.followers {
91 out.push(match now.followers {
92 n if n < 0 => tr("room.followers_off").to_string(),
93 0 => tr("room.followers_on").to_string(),
94 n => trf("room.followers_dur", &[("dur", &fmt_secs(n as u64 * 60))]),
95 });
96 }
97 if was.emote_only != now.emote_only {
98 out.push(on_off(
99 "room.emote_only_on",
100 "room.emote_only_off",
101 now.emote_only,
102 ));
103 }
104 if was.subs_only != now.subs_only {
105 out.push(on_off(
106 "room.sub_only_on",
107 "room.sub_only_off",
108 now.subs_only,
109 ));
110 }
111 out
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::proto::{parse, Event};
118
119 fn full(slow: u32, followers: i32, emote_only: bool, subs_only: bool) -> RoomState {
121 RoomState {
122 slow: Some(slow),
123 followers: Some(followers),
124 emote_only: Some(emote_only),
125 subs_only: Some(subs_only),
126 }
127 }
128 const NONE: RoomState = RoomState {
129 slow: None,
130 followers: None,
131 emote_only: None,
132 subs_only: None,
133 };
134
135 fn whats(v: &[ChatLine]) -> Vec<String> {
136 v.iter()
137 .map(|l| l.note.as_ref().unwrap().what.clone())
138 .collect()
139 }
140
141 #[test]
142 fn first_state_is_silent_then_changes_speak() {
143 let mut r = RoomModes::new();
144 assert!(r.update("c", full(0, -1, false, false)).is_empty());
145 for (next, want) in [
146 (full(30, -1, false, false), vec!["slow mode 30s"]),
147 (full(120, -1, false, false), vec!["slow mode 2m"]),
148 (full(0, -1, false, false), vec!["slow mode off"]),
149 (full(0, 10, false, false), vec!["followers-only 10m"]),
150 (full(0, 0, false, false), vec!["followers-only on"]),
151 (full(0, 90, false, false), vec!["followers-only 90m"]),
152 (full(0, -1, false, false), vec!["followers-only off"]),
153 (full(0, -1, true, false), vec!["emote-only on"]),
154 (full(0, -1, false, false), vec!["emote-only off"]),
155 (full(0, -1, false, true), vec!["sub-only on"]),
156 (
157 full(5, 1, true, true),
158 vec!["slow mode 5s", "followers-only 1m", "emote-only on"],
159 ),
160 ] {
161 let got = r.update("c", next);
162 assert_eq!(whats(&got), want, "{next:?}");
163 assert!(got
164 .iter()
165 .all(|l| l.note.as_ref().unwrap().kind == NoteKind::Mode));
166 }
167 }
168
169 #[test]
170 fn a_partial_update_changes_only_what_it_names() {
171 let mut r = RoomModes::new();
172 r.update("c", full(0, 10, true, true));
173 let got = r.update(
175 "c",
176 RoomState {
177 slow: Some(30),
178 ..NONE
179 },
180 );
181 assert_eq!(whats(&got), ["slow mode 30s"]);
182 assert!(
183 r.update("c", NONE).is_empty(),
184 "an empty update says nothing"
185 );
186 let got = r.update(
187 "c",
188 RoomState {
189 emote_only: Some(false),
190 ..NONE
191 },
192 );
193 assert_eq!(whats(&got), ["emote-only off"]);
194 let got = r.update("c", full(30, 10, false, true));
196 assert!(got.is_empty(), "state was carried through: {got:?}");
197 }
198
199 #[test]
200 fn a_partial_first_state_fills_the_rest_with_off() {
201 let mut r = RoomModes::new();
202 assert!(r
203 .update(
204 "c",
205 RoomState {
206 slow: Some(30),
207 ..NONE
208 }
209 )
210 .is_empty());
211 assert_eq!(
212 whats(&r.update("c", full(30, -1, true, false))),
213 ["emote-only on"]
214 );
215 }
216
217 #[test]
218 fn an_unchanged_state_and_other_channels_say_nothing() {
219 let mut r = RoomModes::new();
220 let off = full(0, -1, false, false);
221 let slow9 = full(9, -1, false, false);
222 r.update("a", off);
223 assert!(r.update("a", off).is_empty());
224 assert!(r.update("b", slow9).is_empty(), "b's first");
225 assert!(r.update("B", slow9).is_empty(), "case-blind");
226 assert_eq!(r.update("a", slow9).len(), 1);
227 }
228
229 #[test]
230 fn the_frame_parses() {
231 let raw = r#"{"type":"irc:message","channel":"Chan","message":{"type":"roomstate",
232 "emoteOnly":true,"followersOnly":10,"slow":30,"subsOnly":false}}"#;
233 match parse(raw) {
234 Event::Room(c, s) => {
235 assert_eq!(c, "Chan");
236 assert_eq!(s, full(30, 10, true, false));
237 }
238 e => panic!("{e:?}"),
239 }
240 assert_eq!(
241 parse(r#"{"type":"irc:message","message":{"type":"roomstate"}}"#),
242 Event::Ignore
243 );
244 let part =
246 r#"{"type":"irc:message","channel":"c","message":{"type":"roomstate","slow":30}}"#;
247 assert!(matches!(
248 parse(part),
249 Event::Room(
250 _,
251 RoomState {
252 slow: Some(30),
253 followers: None,
254 emote_only: None,
255 subs_only: None
256 }
257 )
258 ));
259 }
260}