1pub mod block;
11pub mod blockid;
12pub mod clip;
13pub mod complete;
14pub mod dupe;
15pub mod edit;
16pub mod emote;
17pub mod heat;
18pub mod hsbadge;
19pub mod i18n;
20pub mod iso;
21pub mod key;
22pub mod layout;
23pub mod mock;
24pub mod paint;
25pub mod proto;
26pub mod room;
27pub mod sanitize;
28pub mod scroll;
29pub mod slash;
30pub mod social;
31pub mod streamev;
32
33use std::collections::{HashSet, VecDeque};
34
35#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
37pub enum Platform {
38 Twitch,
39 Kick,
40 Youtube,
42}
43
44impl Platform {
45 pub fn tag(self) -> &'static str {
46 match self {
47 Platform::Twitch => "tw",
48 Platform::Kick => "kk",
49 Platform::Youtube => "yt",
50 }
51 }
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub enum TabKind {
58 Channel,
59 Mentions,
60 Feed,
61 Whispers,
62}
63
64impl TabKind {
65 pub const SPECIAL: [TabKind; 3] = [TabKind::Mentions, TabKind::Feed, TabKind::Whispers];
67
68 pub fn name(self) -> &'static str {
70 match self {
71 TabKind::Channel => "channel",
72 TabKind::Mentions => "mentions",
73 TabKind::Feed => "feed",
74 TabKind::Whispers => "whispers",
75 }
76 }
77
78 pub fn parse(s: &str) -> Option<TabKind> {
79 TabKind::SPECIAL
80 .into_iter()
81 .find(|k| k.name().eq_ignore_ascii_case(s.trim()))
82 }
83
84 pub fn is_special(self) -> bool {
85 self != TabKind::Channel
86 }
87}
88
89#[derive(Clone, Debug, PartialEq)]
91pub struct Tag {
92 pub text: String,
93 pub kind: TagKind,
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum TagKind {
98 Origin,
100 Platform,
102 Op,
104 ThreadOp,
105 Reply,
106 Heat,
109 Whisper,
110 Dm,
111}
112
113impl Tag {
114 pub fn new(text: &str, kind: TagKind) -> Tag {
115 Tag {
116 text: text.to_string(),
117 kind,
118 }
119 }
120
121 pub fn platform() -> Tag {
123 Tag::new("", TagKind::Platform)
124 }
125}
126
127impl Platform {
128 pub fn letter(self) -> char {
130 match self {
131 Platform::Twitch => 'T',
132 Platform::Kick => 'K',
133 Platform::Youtube => 'Y',
134 }
135 }
136}
137
138#[derive(Clone, Copy, PartialEq, Eq, Debug)]
144pub enum BadgeKind {
145 Broadcaster,
146 Moderator,
147 Vip,
148 Subscriber,
149 Founder,
150 Staff,
151 GlobalMod,
152 Verified,
153 Og,
154 Member,
155 Premium,
156 Turbo,
157 Bits,
158 Gifter,
159 Predictions,
160 Hype,
161 Artist,
162 Other(char),
163}
164
165#[derive(Clone, Copy, PartialEq, Eq, Debug)]
167pub struct Badge {
168 pub kind: BadgeKind,
169 pub ver: u16,
170}
171
172impl Badge {
173 pub fn new(kind: BadgeKind) -> Badge {
174 Badge { kind, ver: 0 }
175 }
176
177 pub fn from_name(name: &str, ver: u16) -> Option<Badge> {
181 use BadgeKind as K;
182 let lower = name.to_ascii_lowercase();
183 let kind = match lower.as_str() {
184 "broadcaster" | "owner" => K::Broadcaster,
185 "moderator" => K::Moderator,
186 "vip" => K::Vip,
187 "subscriber" => K::Subscriber,
188 "founder" => K::Founder,
189 "staff" | "admin" => K::Staff,
190 "global_mod" => K::GlobalMod,
191 "verified" | "partner" => K::Verified,
192 "og" => K::Og,
193 "member" => K::Member,
194 "premium" => K::Premium,
195 "turbo" => K::Turbo,
196 "bits" | "bits-leader" | "anonymous-cheerer" => K::Bits,
197 "sub-gifter" | "sub_gifter" | "sub-gift-leader" => K::Gifter,
198 "predictions" => K::Predictions,
199 "hype-train" => K::Hype,
200 "artist" | "artist-badge" => K::Artist,
201 other => K::Other(other.chars().next().filter(|c| c.is_alphanumeric())?),
202 };
203 Some(Badge { kind, ver })
204 }
205
206 pub fn glyph(self) -> char {
209 use BadgeKind as K;
210 match self.kind {
211 K::Broadcaster => 'B',
212 K::Moderator => 'M',
213 K::Vip => 'V',
214 K::Subscriber => 'S',
215 K::Founder => 'F',
216 K::Staff => 'A',
217 K::GlobalMod => 'G',
218 K::Verified => '✓',
219 K::Og => 'O',
220 K::Member => 'm',
221 K::Premium => 'P',
222 K::Turbo => 'T',
223 K::Bits => '◆',
224 K::Gifter => '✦',
225 K::Predictions => '%',
226 K::Hype => 'H',
227 K::Artist => 'a',
228 K::Other(c) => c.to_ascii_uppercase(),
229 }
230 }
231}
232
233#[derive(Clone, Copy, PartialEq, Eq, Debug)]
236pub enum NoteKind {
237 Sub,
239 Gift,
241 Cheer,
243 Raid,
245 Redeem,
247 Live,
249 Offline,
251 Category,
253 Notice,
255 Spike,
257 Mod,
259 Announce(Tint),
261 Streak,
263 Milestone,
265 Charity,
267 ModAnniv,
269 Ritual,
271 Mode,
273}
274
275#[derive(Clone, Copy, PartialEq, Eq, Debug)]
278pub enum Tint {
279 Plain,
280 Blue,
281 Green,
282 Yellow,
283 Magenta,
284}
285
286impl Tint {
287 pub fn from_name(s: &str) -> Tint {
289 match s.to_ascii_uppercase().as_str() {
290 "BLUE" => Tint::Blue,
291 "GREEN" => Tint::Green,
292 "ORANGE" => Tint::Yellow,
293 "PURPLE" => Tint::Magenta,
294 _ => Tint::Plain,
295 }
296 }
297}
298
299#[derive(Clone, Debug, Default, PartialEq)]
302pub struct Extra {
303 pub reply_text: String,
305 pub action: bool,
307 pub first: bool,
309 pub highlight: bool,
311 pub ts: u64,
313}
314
315#[derive(Clone, Debug, PartialEq)]
320pub struct Note {
321 pub kind: NoteKind,
322 pub what: String,
323}
324
325#[derive(Clone, Debug)]
329pub struct Message {
330 pub platform: Platform,
333 pub user: String,
334 pub text: String,
335 pub color: Option<String>,
336 pub badges: Vec<Badge>,
337 pub reply_to: Option<String>,
339 pub id: Option<String>,
341 pub gone: Option<String>,
344 pub note: Option<Note>,
347 pub uid: Option<String>,
350 pub extra: Extra,
351 pub emotes: Option<std::sync::Arc<emote::EmoteSet>>,
354 pub tags: Vec<Tag>,
357 pub heat: f64,
358}
359
360#[derive(Clone, Debug, PartialEq, Eq)]
362pub struct Pin {
363 pub id: String,
364 pub user: String,
365 pub text: String,
366}
367
368impl Message {
369 pub fn refs(&self) -> &emote::EmoteSet {
371 self.emotes.as_deref().unwrap_or(emote::EmoteSet::none())
372 }
373}
374
375#[derive(Clone, Debug, Default)]
382struct Shown {
383 ids: HashSet<(Platform, String)>,
384 order: VecDeque<(Platform, String)>,
385 cap: usize,
386}
387
388impl Shown {
389 fn first(&mut self, platform: Platform, channel: &str, id: &str) -> bool {
391 let key = (
392 platform,
393 format!("{}\u{1}{id}", channel.to_ascii_lowercase()),
394 );
395 if self.ids.contains(&key) {
396 return false;
397 }
398 if self.order.len() >= self.cap {
399 if let Some(old) = self.order.pop_front() {
400 self.ids.remove(&old);
401 }
402 }
403 self.ids.insert(key.clone());
404 self.order.push_back(key);
405 true
406 }
407}
408
409#[derive(Clone, Debug)]
413pub struct Channel {
414 pub kind: TabKind,
416 pub name: String,
417 pub platform: Platform,
419 pub extra: Vec<(Platform, String)>,
422 pub temp: bool,
425 pub heat: f64,
426 pub last_ms: u64,
428 pub messages: VecDeque<Message>,
429 unread: u32,
431 pinged: bool,
433 evented: bool,
435 pin: Option<Pin>,
437 pin_dismissed: Option<String>,
439 pub seq: u64,
442 cap: usize,
443 shown: Shown,
445}
446
447#[derive(Clone, Copy, PartialEq, Eq, Debug)]
449pub enum Unread {
450 None,
451 Chat,
452 Event,
454 Ping,
455}
456
457impl Channel {
458 pub fn new(name: &str, platform: Platform, cap: usize) -> Channel {
459 Channel {
460 kind: TabKind::Channel,
461 name: name.to_string(),
462 platform,
463 extra: Vec::new(),
464 temp: false,
465 heat: 0.0,
466 last_ms: 0,
467 messages: VecDeque::with_capacity(cap),
468 unread: 0,
469 pinged: false,
470 evented: false,
471 pin: None,
472 pin_dismissed: None,
473 seq: 0,
474 cap,
475 shown: Shown {
476 cap: cap.saturating_mul(4),
477 ..Shown::default()
478 },
479 }
480 }
481
482 pub fn special(kind: TabKind, cap: usize) -> Channel {
485 Channel {
486 kind,
487 ..Channel::new(kind.name(), Platform::Twitch, cap)
488 }
489 }
490
491 pub fn deliver(&mut self, msg: Message) {
494 if self.messages.len() == self.cap {
495 self.messages.pop_front();
496 }
497 self.messages.push_back(msg);
498 self.seq += 1;
499 self.unread = self.unread.saturating_add(1);
500 }
501
502 pub fn unread(&self) -> Unread {
504 if self.pinged {
505 Unread::Ping
506 } else if self.evented {
507 Unread::Event
508 } else if self.unread > 0 {
509 Unread::Chat
510 } else {
511 Unread::None
512 }
513 }
514
515 pub fn ping(&mut self) {
517 self.pinged = true;
518 }
519
520 pub fn set_pin(&mut self, pin: Option<Pin>) {
522 self.pin = pin;
523 }
524
525 pub fn visible_pin(&self) -> Option<&Pin> {
527 self.pin
528 .as_ref()
529 .filter(|p| self.pin_dismissed.as_deref() != Some(p.id.as_str()))
530 }
531
532 pub fn dismiss_pin(&mut self) -> bool {
534 let id = self.visible_pin().map(|p| p.id.clone());
535 let some = id.is_some();
536 if some {
537 self.pin_dismissed = id;
538 }
539 some
540 }
541
542 pub fn flag_event(&mut self) {
544 self.evented = true;
545 }
546
547 pub fn system(&mut self, msg: Message) {
550 if self.messages.len() == self.cap {
551 self.messages.pop_front();
552 }
553 self.messages.push_back(msg);
554 self.seq += 1;
555 }
556
557 pub fn insert_by_ts(&mut self, msg: Message) -> bool {
562 let at = self
563 .messages
564 .iter()
565 .rposition(|m| m.extra.ts <= msg.extra.ts)
566 .map_or(0, |i| i + 1);
567 if self.messages.len() == self.cap {
568 if at == 0 {
569 return false;
570 }
571 self.messages.pop_front();
572 self.messages.insert(at - 1, msg);
573 } else {
574 self.messages.insert(at, msg);
575 }
576 true
577 }
578
579 pub fn apply(&mut self, d: &proto::Delete) -> usize {
583 let mut n = 0;
584 for m in self.messages.iter_mut() {
585 if m.platform != d.platform || m.note.is_some() || m.gone.is_some() {
586 continue;
587 }
588 let hit = match &d.target {
589 proto::Target::Msg(id) => m.id.as_deref() == Some(id.as_str()),
590 proto::Target::User(u) => !m.user.is_empty() && m.user.eq_ignore_ascii_case(u),
591 proto::Target::All => true,
592 };
593 if hit {
594 m.gone = Some(d.label.clone());
595 n += 1;
596 }
597 }
598 n
599 }
600
601 pub fn mark_seen(&mut self) {
603 self.unread = 0;
604 self.pinged = false;
605 self.evented = false;
606 }
607
608 pub fn subs(&self) -> impl Iterator<Item = (Platform, &str)> {
610 let own = (self.kind == TabKind::Channel).then_some((self.platform, self.name.as_str()));
612 own.into_iter()
613 .chain(self.extra.iter().map(|(p, n)| (*p, n.as_str())))
614 }
615
616 pub fn matches(&self, platform: Platform, name: &str) -> bool {
618 self.subs()
619 .any(|(p, n)| p == platform && n.eq_ignore_ascii_case(name))
620 }
621
622 pub fn merged(&self) -> bool {
624 !self.extra.is_empty()
625 }
626
627 pub fn cool(&mut self, now_ms: u64) {
629 if self.last_ms == 0 {
630 self.last_ms = now_ms;
631 return;
632 }
633 let dt = now_ms.saturating_sub(self.last_ms) as f64;
634 self.heat = heat::decay(self.heat, dt);
635 self.last_ms = now_ms;
636 }
637
638 pub fn first_sight(&mut self, platform: Platform, channel: &str, id: Option<&str>) -> bool {
642 match id.filter(|i| !i.is_empty()) {
643 Some(id) => self.shown.first(platform, channel, id),
644 None => true,
645 }
646 }
647
648 pub fn backfill(&mut self, channel: &str, msgs: Vec<Message>) {
654 for m in msgs.into_iter().rev() {
655 if self.messages.len() == self.cap {
656 break;
657 }
658 if !self.first_sight(m.platform, channel, m.id.as_deref()) {
659 continue;
660 }
661 self.messages.push_front(m);
662 }
663 }
664
665 pub fn record(&mut self, mut msg: Message, now_ms: u64) {
668 self.cool(now_ms);
669 self.heat += heat::INCREMENT;
670 msg.heat = self.heat;
671 if self.messages.len() == self.cap {
672 self.messages.pop_front();
673 }
674 self.messages.push_back(msg);
675 self.seq += 1;
676 self.unread = self.unread.saturating_add(1);
677 }
678}
679
680#[cfg(test)]
681mod channel_tests {
682 use super::*;
683
684 #[test]
685 fn unread_goes_none_chat_ping_and_clears_when_seen() {
686 let mut c = Channel::new("x", Platform::Twitch, 8);
687 assert_eq!(c.unread(), Unread::None);
688 c.record(msg("a", "hi"), 1);
689 assert_eq!(c.unread(), Unread::Chat);
690 c.flag_event();
691 assert_eq!(c.unread(), Unread::Event);
692 c.record(msg("a", "again"), 1);
693 assert_eq!(
694 c.unread(),
695 Unread::Event,
696 "chat does not downgrade an event"
697 );
698 c.ping();
699 assert_eq!(c.unread(), Unread::Ping);
700 c.record(msg("b", "yo"), 2);
701 assert_eq!(
702 c.unread(),
703 Unread::Ping,
704 "a later plain line does not downgrade a ping"
705 );
706 c.mark_seen();
707 assert_eq!(c.unread(), Unread::None);
708 }
709
710 #[test]
711 fn a_dismissed_pin_stays_hidden_until_a_new_one_lands() {
712 let pin = |id: &str| Pin {
713 id: id.into(),
714 user: "u".into(),
715 text: "t".into(),
716 };
717 let mut c = Channel::new("x", Platform::Twitch, 8);
718 assert!(!c.dismiss_pin(), "nothing to dismiss");
719 c.set_pin(Some(pin("a")));
720 assert_eq!(c.visible_pin().map(|p| p.id.as_str()), Some("a"));
721 assert!(c.dismiss_pin());
722 assert!(c.visible_pin().is_none());
723 c.set_pin(Some(pin("a")));
724 assert!(c.visible_pin().is_none(), "same pin re-sent stays hidden");
725 c.set_pin(Some(pin("b")));
726 assert!(c.visible_pin().is_some());
727 c.set_pin(None);
728 assert!(c.visible_pin().is_none());
729 }
730
731 fn gone_after(c: &Channel) -> Vec<Option<String>> {
732 c.messages.iter().map(|m| m.gone.clone()).collect()
733 }
734
735 fn del(target: proto::Target, label: &str) -> proto::Delete {
736 proto::Delete {
737 platform: Platform::Twitch,
738 channel: "x".into(),
739 target,
740 label: label.into(),
741 }
742 }
743
744 #[test]
745 fn delete_by_id_marks_only_that_line() {
746 let mut c = Channel::new("x", Platform::Twitch, 8);
747 for (i, t) in ["a", "b", "c"].iter().enumerate() {
748 let mut m = msg("u", t);
749 m.id = Some(format!("id{i}"));
750 c.record(m, 1);
751 }
752 assert_eq!(
753 c.apply(&del(proto::Target::Msg("id1".into()), "deleted")),
754 1
755 );
756 assert_eq!(gone_after(&c), vec![None, Some("deleted".into()), None]);
757 assert_eq!(
758 c.apply(&del(proto::Target::Msg("id1".into()), "deleted")),
759 0,
760 "already marked"
761 );
762 }
763
764 #[test]
765 fn timeout_marks_all_of_a_users_chat_lines_not_events() {
766 let mut c = Channel::new("x", Platform::Twitch, 8);
767 c.record(msg("Troll", "a"), 1);
768 c.record(msg("other", "b"), 1);
769 let mut ev = msg("troll", "");
770 ev.note = Some(Note {
771 kind: NoteKind::Sub,
772 what: "subbed".into(),
773 });
774 c.record(ev, 1);
775 c.record(msg("TROLL", "c"), 1);
776 assert_eq!(
777 c.apply(&del(proto::Target::User("troll".into()), "timed out 10m")),
778 2
779 );
780 let g = gone_after(&c);
781 assert_eq!(g[0].as_deref(), Some("timed out 10m"));
782 assert_eq!(g[1], None);
783 assert_eq!(g[2], None, "events stay");
784 assert_eq!(g[3].as_deref(), Some("timed out 10m"));
785 }
786
787 #[test]
788 fn clear_marks_everything_on_that_platform_only() {
789 let mut c = Channel::new("x", Platform::Twitch, 8);
790 c.record(msg("a", "1"), 1);
791 let mut k = msg("b", "2");
792 k.platform = Platform::Kick;
793 c.record(k, 1);
794 c.apply(&del(proto::Target::All, "cleared"));
795 let g = gone_after(&c);
796 assert_eq!(g[0].as_deref(), Some("cleared"));
797 assert_eq!(g[1], None);
798 }
799
800 #[test]
801 fn seq_counts_appends_through_eviction() {
802 let mut c = Channel::new("x", Platform::Twitch, 2);
803 for i in 0..5 {
804 c.record(msg("u", &i.to_string()), 1);
805 }
806 assert_eq!((c.seq, c.messages.len()), (5, 2));
807 }
808
809 #[test]
810 fn backfill_is_not_unread() {
811 let mut c = Channel::new("x", Platform::Twitch, 8);
812 c.backfill("x", vec![msg("a", "old")]);
813 assert_eq!(c.unread(), Unread::None);
814 }
815
816 fn msg(user: &str, text: &str) -> Message {
817 Message {
818 platform: Platform::Twitch,
819 user: user.into(),
820 text: text.into(),
821 color: None,
822 badges: Vec::new(),
823 id: None,
824 gone: None,
825 reply_to: None,
826 note: None,
827 extra: Default::default(),
828 emotes: None,
829 tags: Vec::new(),
830 heat: 0.0,
831 uid: None,
832 }
833 }
834
835 #[test]
836 fn insert_by_ts_lands_in_time_order_without_heat_or_unread() {
837 let at = |t: u64, text: &str| {
838 let mut m = msg("u", text);
839 m.extra.ts = t;
840 m
841 };
842 let mut ch = Channel::new("c", Platform::Twitch, 3);
843 ch.system(at(10, "a"));
844 ch.system(at(30, "c"));
845 assert!(ch.insert_by_ts(at(20, "b")));
846 let texts = |ch: &Channel| {
847 ch.messages
848 .iter()
849 .map(|m| m.text.clone())
850 .collect::<Vec<_>>()
851 };
852 assert_eq!(texts(&ch), ["a", "b", "c"]);
853 assert_eq!((ch.heat, ch.unread()), (0.0, Unread::None));
854 assert!(!ch.insert_by_ts(at(5, "old")));
856 assert!(ch.insert_by_ts(at(25, "mid")));
857 assert_eq!(texts(&ch), ["b", "mid", "c"]);
858 assert!(ch.insert_by_ts(at(40, "new")));
859 assert_eq!(texts(&ch), ["mid", "c", "new"]);
860 }
861
862 fn with_id(user: &str, text: &str, id: &str) -> Message {
863 Message {
864 id: Some(id.into()),
865 ..msg(user, text)
866 }
867 }
868
869 fn live(ch: &mut Channel, m: Message) -> bool {
871 let fresh = ch.first_sight(m.platform, "c", m.id.as_deref());
872 if fresh {
873 ch.record(m, 1);
874 }
875 fresh
876 }
877
878 #[test]
879 fn backfill_prepends_in_order_and_dedupes_against_live_by_id() {
880 let mut ch = Channel::new("c", Platform::Twitch, 10);
881 assert!(live(&mut ch, with_id("live", "already here", "L")));
882 ch.backfill(
883 "c",
884 vec![
885 with_id("a", "one", "1"),
886 with_id("live", "already here", "L"), with_id("b", "two", "2"),
888 ],
889 );
890 let got: Vec<&str> = ch.messages.iter().map(|m| m.text.as_str()).collect();
891 assert_eq!(got, vec!["one", "two", "already here"]);
892 }
893
894 #[test]
895 fn backfill_never_dedupes_by_text() {
896 let mut ch = Channel::new("c", Platform::Twitch, 10);
899 assert!(live(&mut ch, with_id("amy", "LUL", "L1")));
900 ch.backfill(
901 "c",
902 vec![
903 with_id("amy", "LUL", "H1"),
904 with_id("amy", "LUL", "H2"),
905 msg("amy", "LUL"),
906 msg("amy", "LUL"),
907 ],
908 );
909 assert_eq!(ch.messages.len(), 5);
910 }
911
912 #[test]
913 fn history_is_remembered_so_the_live_copy_is_dropped() {
914 let mut ch = Channel::new("c", Platform::Twitch, 10);
915 ch.backfill("c", vec![with_id("a", "one", "1")]);
916 assert!(
917 !live(&mut ch, with_id("a", "one", "1")),
918 "history, then live"
919 );
920 assert_eq!(ch.messages.len(), 1);
921 }
922
923 #[test]
924 fn first_sight_is_once_per_platform_channel_and_id() {
925 let mut c = Channel::new("x", Platform::Twitch, 8);
926 assert!(c.first_sight(Platform::Twitch, "x", Some("a")));
927 assert!(!c.first_sight(Platform::Twitch, "x", Some("a")), "a replay");
928 assert!(
929 !c.first_sight(Platform::Twitch, "X", Some("a")),
930 "case is not a channel"
931 );
932 assert!(
933 c.first_sight(Platform::Twitch, "y", Some("a")),
934 "shared chat: another channel's copy"
935 );
936 assert!(
937 c.first_sight(Platform::Kick, "x", Some("a")),
938 "another platform"
939 );
940 assert!(c.first_sight(Platform::Twitch, "x", Some("b")));
941 assert!(c.first_sight(Platform::Twitch, "x", None));
943 assert!(c.first_sight(Platform::Twitch, "x", None));
944 assert!(c.first_sight(Platform::Twitch, "x", Some("")));
945 assert!(c.first_sight(Platform::Twitch, "x", Some("")));
946 }
947
948 #[test]
949 fn the_id_memory_outlives_the_ring_and_forgets_oldest_first() {
950 let mut ch = Channel::new("c", Platform::Twitch, 2);
953 for i in 0..20 {
954 assert!(live(&mut ch, with_id("u", "x", &i.to_string())));
955 }
956 assert_eq!(ch.messages.len(), 2);
957 assert!(!live(&mut ch, with_id("u", "x", "19")), "in the ring");
958 assert!(
959 !live(&mut ch, with_id("u", "x", "14")),
960 "out of the ring, still remembered"
961 );
962 assert!(
963 live(&mut ch, with_id("u", "x", "0")),
964 "forgotten: the memory is bounded"
965 );
966 assert!(ch.shown.ids.len() <= 8 && ch.shown.order.len() <= 8);
967 }
968
969 #[test]
970 fn backfill_respects_the_cap_keeping_the_newest_history() {
971 let mut ch = Channel::new("c", Platform::Twitch, 3);
972 ch.record(msg("live", "now"), 1);
973 ch.backfill("c", (0..5).map(|i| msg("u", &format!("h{i}"))).collect());
974 assert_eq!(ch.messages.len(), 3);
975 let got: Vec<&str> = ch.messages.iter().map(|m| m.text.as_str()).collect();
976 assert_eq!(got, vec!["h3", "h4", "now"]);
978 }
979
980 #[test]
981 fn merged_tab_matches_every_sub_case_insensitively() {
982 let mut ch = Channel::new("xqc", Platform::Twitch, 10);
983 ch.extra = vec![
984 (Platform::Kick, "xqc".into()),
985 (Platform::Youtube, "Vid_123".into()),
986 ];
987 assert!(ch.merged());
988 assert!(ch.matches(Platform::Twitch, "XQC"));
989 assert!(ch.matches(Platform::Kick, "xqc"));
990 assert!(ch.matches(Platform::Youtube, "vid_123"));
991 assert!(!ch.matches(Platform::Kick, "other"));
992 assert_eq!(ch.subs().count(), 3);
993 assert!(!Channel::new("xqc", Platform::Twitch, 10).merged());
994 }
995
996 #[test]
997 fn backfill_leaves_heat_alone() {
998 let mut ch = Channel::new("c", Platform::Kick, 10);
999 ch.backfill("c", vec![msg("a", "x")]);
1000 assert_eq!(ch.heat, 0.0);
1001 assert_eq!(ch.messages[0].heat, 0.0);
1002 }
1003
1004 #[test]
1005 fn special_tabs_join_nothing_and_match_nothing() {
1006 let mut c = Channel::special(TabKind::Mentions, 10);
1007 assert_eq!(c.kind, TabKind::Mentions);
1008 assert_eq!(c.subs().count(), 0);
1009 assert!(!c.matches(Platform::Twitch, "mentions")); let mut m = msg("bob", "hey");
1011 m.heat = 42.0;
1012 c.deliver(m);
1013 assert_eq!(c.messages[0].heat, 42.0, "row keeps its own heat");
1014 assert_eq!(c.heat, 0.0);
1015 assert_eq!(c.unread(), Unread::Chat);
1016 c.ping();
1017 assert_eq!(c.unread(), Unread::Ping);
1018 }
1019
1020 #[test]
1021 fn tab_kinds_parse_and_pin_in_order() {
1022 assert_eq!(TabKind::parse("Feed"), Some(TabKind::Feed));
1023 assert_eq!(TabKind::parse("xqc"), None);
1024 assert_eq!(TabKind::parse("channel"), None);
1025 assert!(TabKind::Mentions < TabKind::Feed && TabKind::Feed < TabKind::Whispers);
1026 assert!(!TabKind::Channel.is_special());
1027 }
1028}