1pub mod block;
11pub mod clip;
12pub mod complete;
13pub mod dupe;
14pub mod edit;
15pub mod emote;
16pub mod heat;
17pub mod hsbadge;
18pub mod iso;
19pub mod key;
20pub mod layout;
21pub mod mock;
22pub mod paint;
23pub mod proto;
24pub mod room;
25pub mod sanitize;
26pub mod scroll;
27pub mod slash;
28pub mod social;
29pub mod streamev;
30pub mod vi;
31
32use std::collections::VecDeque;
33
34#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
36pub enum Platform {
37 Twitch,
38 Kick,
39 Youtube,
41}
42
43impl Platform {
44 pub fn tag(self) -> &'static str {
45 match self {
46 Platform::Twitch => "tw",
47 Platform::Kick => "kk",
48 Platform::Youtube => "yt",
49 }
50 }
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
56pub enum TabKind {
57 Channel,
58 Mentions,
59 Feed,
60 Whispers,
61}
62
63impl TabKind {
64 pub const SPECIAL: [TabKind; 3] = [TabKind::Mentions, TabKind::Feed, TabKind::Whispers];
66
67 pub fn name(self) -> &'static str {
69 match self {
70 TabKind::Channel => "channel",
71 TabKind::Mentions => "mentions",
72 TabKind::Feed => "feed",
73 TabKind::Whispers => "whispers",
74 }
75 }
76
77 pub fn parse(s: &str) -> Option<TabKind> {
78 TabKind::SPECIAL
79 .into_iter()
80 .find(|k| k.name().eq_ignore_ascii_case(s.trim()))
81 }
82
83 pub fn is_special(self) -> bool {
84 self != TabKind::Channel
85 }
86}
87
88#[derive(Clone, Debug, PartialEq)]
90pub struct Tag {
91 pub text: String,
92 pub kind: TagKind,
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub enum TagKind {
97 Origin,
99 Platform,
101 Op,
103 ThreadOp,
104 Reply,
105 Heat,
108 Whisper,
109 Dm,
110}
111
112impl Tag {
113 pub fn new(text: &str, kind: TagKind) -> Tag {
114 Tag {
115 text: text.to_string(),
116 kind,
117 }
118 }
119
120 pub fn platform() -> Tag {
122 Tag::new("", TagKind::Platform)
123 }
124}
125
126impl Platform {
127 pub fn letter(self) -> char {
129 match self {
130 Platform::Twitch => 'T',
131 Platform::Kick => 'K',
132 Platform::Youtube => 'Y',
133 }
134 }
135}
136
137#[derive(Clone, Copy, PartialEq, Eq, Debug)]
143pub enum BadgeKind {
144 Broadcaster,
145 Moderator,
146 Vip,
147 Subscriber,
148 Founder,
149 Staff,
150 GlobalMod,
151 Verified,
152 Og,
153 Member,
154 Premium,
155 Turbo,
156 Bits,
157 Gifter,
158 Predictions,
159 Hype,
160 Artist,
161 Other(char),
162}
163
164#[derive(Clone, Copy, PartialEq, Eq, Debug)]
166pub struct Badge {
167 pub kind: BadgeKind,
168 pub ver: u16,
169}
170
171impl Badge {
172 pub fn new(kind: BadgeKind) -> Badge {
173 Badge { kind, ver: 0 }
174 }
175
176 pub fn from_name(name: &str, ver: u16) -> Option<Badge> {
180 use BadgeKind as K;
181 let lower = name.to_ascii_lowercase();
182 let kind = match lower.as_str() {
183 "broadcaster" | "owner" => K::Broadcaster,
184 "moderator" => K::Moderator,
185 "vip" => K::Vip,
186 "subscriber" => K::Subscriber,
187 "founder" => K::Founder,
188 "staff" | "admin" => K::Staff,
189 "global_mod" => K::GlobalMod,
190 "verified" | "partner" => K::Verified,
191 "og" => K::Og,
192 "member" => K::Member,
193 "premium" => K::Premium,
194 "turbo" => K::Turbo,
195 "bits" | "bits-leader" | "anonymous-cheerer" => K::Bits,
196 "sub-gifter" | "sub_gifter" | "sub-gift-leader" => K::Gifter,
197 "predictions" => K::Predictions,
198 "hype-train" => K::Hype,
199 "artist" | "artist-badge" => K::Artist,
200 other => K::Other(other.chars().next().filter(|c| c.is_alphanumeric())?),
201 };
202 Some(Badge { kind, ver })
203 }
204
205 pub fn glyph(self) -> char {
208 use BadgeKind as K;
209 match self.kind {
210 K::Broadcaster => 'B',
211 K::Moderator => 'M',
212 K::Vip => 'V',
213 K::Subscriber => 'S',
214 K::Founder => 'F',
215 K::Staff => 'A',
216 K::GlobalMod => 'G',
217 K::Verified => '✓',
218 K::Og => 'O',
219 K::Member => 'm',
220 K::Premium => 'P',
221 K::Turbo => 'T',
222 K::Bits => '◆',
223 K::Gifter => '✦',
224 K::Predictions => '%',
225 K::Hype => 'H',
226 K::Artist => 'a',
227 K::Other(c) => c.to_ascii_uppercase(),
228 }
229 }
230}
231
232#[derive(Clone, Copy, PartialEq, Eq, Debug)]
235pub enum NoteKind {
236 Sub,
238 Gift,
240 Cheer,
242 Raid,
244 Redeem,
246 Live,
248 Offline,
250 Category,
252 Notice,
254 Spike,
256 Mod,
258 Announce(Tint),
260 Streak,
262 Milestone,
264 Charity,
266 ModAnniv,
268 Ritual,
270 Mode,
272}
273
274#[derive(Clone, Copy, PartialEq, Eq, Debug)]
277pub enum Tint {
278 Plain,
279 Blue,
280 Green,
281 Yellow,
282 Magenta,
283}
284
285impl Tint {
286 pub fn from_name(s: &str) -> Tint {
288 match s.to_ascii_uppercase().as_str() {
289 "BLUE" => Tint::Blue,
290 "GREEN" => Tint::Green,
291 "ORANGE" => Tint::Yellow,
292 "PURPLE" => Tint::Magenta,
293 _ => Tint::Plain,
294 }
295 }
296}
297
298#[derive(Clone, Debug, Default, PartialEq)]
301pub struct Extra {
302 pub reply_text: String,
304 pub action: bool,
306 pub first: bool,
308 pub highlight: bool,
310 pub ts: u64,
312}
313
314#[derive(Clone, Debug, PartialEq)]
319pub struct Note {
320 pub kind: NoteKind,
321 pub what: String,
322}
323
324#[derive(Clone, Debug)]
328pub struct Message {
329 pub platform: Platform,
332 pub user: String,
333 pub text: String,
334 pub color: Option<String>,
335 pub badges: Vec<Badge>,
336 pub reply_to: Option<String>,
338 pub id: Option<String>,
340 pub gone: Option<String>,
343 pub note: Option<Note>,
346 pub uid: Option<String>,
349 pub extra: Extra,
350 pub emotes: Option<std::sync::Arc<emote::EmoteSet>>,
353 pub tags: Vec<Tag>,
356 pub heat: f64,
357}
358
359#[derive(Clone, Debug, PartialEq, Eq)]
361pub struct Pin {
362 pub id: String,
363 pub user: String,
364 pub text: String,
365}
366
367impl Message {
368 pub fn refs(&self) -> &emote::EmoteSet {
370 self.emotes.as_deref().unwrap_or(emote::EmoteSet::none())
371 }
372}
373
374#[derive(Clone, Debug)]
378pub struct Channel {
379 pub kind: TabKind,
381 pub name: String,
382 pub platform: Platform,
384 pub extra: Vec<(Platform, String)>,
387 pub temp: bool,
390 pub heat: f64,
391 pub last_ms: u64,
393 pub messages: VecDeque<Message>,
394 unread: u32,
396 pinged: bool,
398 evented: bool,
400 pin: Option<Pin>,
402 pin_dismissed: Option<String>,
404 pub seq: u64,
407 cap: usize,
408}
409
410#[derive(Clone, Copy, PartialEq, Eq, Debug)]
412pub enum Unread {
413 None,
414 Chat,
415 Event,
417 Ping,
418}
419
420impl Channel {
421 pub fn new(name: &str, platform: Platform, cap: usize) -> Channel {
422 Channel {
423 kind: TabKind::Channel,
424 name: name.to_string(),
425 platform,
426 extra: Vec::new(),
427 temp: false,
428 heat: 0.0,
429 last_ms: 0,
430 messages: VecDeque::with_capacity(cap),
431 unread: 0,
432 pinged: false,
433 evented: false,
434 pin: None,
435 pin_dismissed: None,
436 seq: 0,
437 cap,
438 }
439 }
440
441 pub fn special(kind: TabKind, cap: usize) -> Channel {
444 Channel {
445 kind,
446 ..Channel::new(kind.name(), Platform::Twitch, cap)
447 }
448 }
449
450 pub fn deliver(&mut self, msg: Message) {
453 if self.messages.len() == self.cap {
454 self.messages.pop_front();
455 }
456 self.messages.push_back(msg);
457 self.seq += 1;
458 self.unread = self.unread.saturating_add(1);
459 }
460
461 pub fn unread(&self) -> Unread {
463 if self.pinged {
464 Unread::Ping
465 } else if self.evented {
466 Unread::Event
467 } else if self.unread > 0 {
468 Unread::Chat
469 } else {
470 Unread::None
471 }
472 }
473
474 pub fn ping(&mut self) {
476 self.pinged = true;
477 }
478
479 pub fn set_pin(&mut self, pin: Option<Pin>) {
481 self.pin = pin;
482 }
483
484 pub fn visible_pin(&self) -> Option<&Pin> {
486 self.pin
487 .as_ref()
488 .filter(|p| self.pin_dismissed.as_deref() != Some(p.id.as_str()))
489 }
490
491 pub fn dismiss_pin(&mut self) -> bool {
493 let id = self.visible_pin().map(|p| p.id.clone());
494 let some = id.is_some();
495 if some {
496 self.pin_dismissed = id;
497 }
498 some
499 }
500
501 pub fn flag_event(&mut self) {
503 self.evented = true;
504 }
505
506 pub fn system(&mut self, msg: Message) {
509 if self.messages.len() == self.cap {
510 self.messages.pop_front();
511 }
512 self.messages.push_back(msg);
513 self.seq += 1;
514 }
515
516 pub fn insert_by_ts(&mut self, msg: Message) -> bool {
521 let at = self
522 .messages
523 .iter()
524 .rposition(|m| m.extra.ts <= msg.extra.ts)
525 .map_or(0, |i| i + 1);
526 if self.messages.len() == self.cap {
527 if at == 0 {
528 return false;
529 }
530 self.messages.pop_front();
531 self.messages.insert(at - 1, msg);
532 } else {
533 self.messages.insert(at, msg);
534 }
535 true
536 }
537
538 pub fn apply(&mut self, d: &proto::Delete) -> usize {
542 let mut n = 0;
543 for m in self.messages.iter_mut() {
544 if m.platform != d.platform || m.note.is_some() || m.gone.is_some() {
545 continue;
546 }
547 let hit = match &d.target {
548 proto::Target::Msg(id) => m.id.as_deref() == Some(id.as_str()),
549 proto::Target::User(u) => !m.user.is_empty() && m.user.eq_ignore_ascii_case(u),
550 proto::Target::All => true,
551 };
552 if hit {
553 m.gone = Some(d.label.clone());
554 n += 1;
555 }
556 }
557 n
558 }
559
560 pub fn mark_seen(&mut self) {
562 self.unread = 0;
563 self.pinged = false;
564 self.evented = false;
565 }
566
567 pub fn subs(&self) -> impl Iterator<Item = (Platform, &str)> {
569 let own = (self.kind == TabKind::Channel).then_some((self.platform, self.name.as_str()));
571 own.into_iter()
572 .chain(self.extra.iter().map(|(p, n)| (*p, n.as_str())))
573 }
574
575 pub fn matches(&self, platform: Platform, name: &str) -> bool {
577 self.subs()
578 .any(|(p, n)| p == platform && n.eq_ignore_ascii_case(name))
579 }
580
581 pub fn merged(&self) -> bool {
583 !self.extra.is_empty()
584 }
585
586 pub fn cool(&mut self, now_ms: u64) {
588 if self.last_ms == 0 {
589 self.last_ms = now_ms;
590 return;
591 }
592 let dt = now_ms.saturating_sub(self.last_ms) as f64;
593 self.heat = heat::decay(self.heat, dt);
594 self.last_ms = now_ms;
595 }
596
597 pub fn backfill(&mut self, msgs: Vec<Message>) {
602 for m in msgs.into_iter().rev() {
603 if self.messages.len() == self.cap {
604 break;
605 }
606 if self
607 .messages
608 .iter()
609 .any(|e| e.user == m.user && e.text == m.text)
610 {
611 continue;
612 }
613 self.messages.push_front(m);
614 }
615 }
616
617 pub fn record(&mut self, mut msg: Message, now_ms: u64) {
620 self.cool(now_ms);
621 self.heat += heat::INCREMENT;
622 msg.heat = self.heat;
623 if self.messages.len() == self.cap {
624 self.messages.pop_front();
625 }
626 self.messages.push_back(msg);
627 self.seq += 1;
628 self.unread = self.unread.saturating_add(1);
629 }
630}
631
632#[cfg(test)]
633mod channel_tests {
634 use super::*;
635
636 #[test]
637 fn unread_goes_none_chat_ping_and_clears_when_seen() {
638 let mut c = Channel::new("x", Platform::Twitch, 8);
639 assert_eq!(c.unread(), Unread::None);
640 c.record(msg("a", "hi"), 1);
641 assert_eq!(c.unread(), Unread::Chat);
642 c.flag_event();
643 assert_eq!(c.unread(), Unread::Event);
644 c.record(msg("a", "again"), 1);
645 assert_eq!(
646 c.unread(),
647 Unread::Event,
648 "chat does not downgrade an event"
649 );
650 c.ping();
651 assert_eq!(c.unread(), Unread::Ping);
652 c.record(msg("b", "yo"), 2);
653 assert_eq!(
654 c.unread(),
655 Unread::Ping,
656 "a later plain line does not downgrade a ping"
657 );
658 c.mark_seen();
659 assert_eq!(c.unread(), Unread::None);
660 }
661
662 #[test]
663 fn a_dismissed_pin_stays_hidden_until_a_new_one_lands() {
664 let pin = |id: &str| Pin {
665 id: id.into(),
666 user: "u".into(),
667 text: "t".into(),
668 };
669 let mut c = Channel::new("x", Platform::Twitch, 8);
670 assert!(!c.dismiss_pin(), "nothing to dismiss");
671 c.set_pin(Some(pin("a")));
672 assert_eq!(c.visible_pin().map(|p| p.id.as_str()), Some("a"));
673 assert!(c.dismiss_pin());
674 assert!(c.visible_pin().is_none());
675 c.set_pin(Some(pin("a")));
676 assert!(c.visible_pin().is_none(), "same pin re-sent stays hidden");
677 c.set_pin(Some(pin("b")));
678 assert!(c.visible_pin().is_some());
679 c.set_pin(None);
680 assert!(c.visible_pin().is_none());
681 }
682
683 fn gone_after(c: &Channel) -> Vec<Option<String>> {
684 c.messages.iter().map(|m| m.gone.clone()).collect()
685 }
686
687 fn del(target: proto::Target, label: &str) -> proto::Delete {
688 proto::Delete {
689 platform: Platform::Twitch,
690 channel: "x".into(),
691 target,
692 label: label.into(),
693 }
694 }
695
696 #[test]
697 fn delete_by_id_marks_only_that_line() {
698 let mut c = Channel::new("x", Platform::Twitch, 8);
699 for (i, t) in ["a", "b", "c"].iter().enumerate() {
700 let mut m = msg("u", t);
701 m.id = Some(format!("id{i}"));
702 c.record(m, 1);
703 }
704 assert_eq!(
705 c.apply(&del(proto::Target::Msg("id1".into()), "deleted")),
706 1
707 );
708 assert_eq!(gone_after(&c), vec![None, Some("deleted".into()), None]);
709 assert_eq!(
710 c.apply(&del(proto::Target::Msg("id1".into()), "deleted")),
711 0,
712 "already marked"
713 );
714 }
715
716 #[test]
717 fn timeout_marks_all_of_a_users_chat_lines_not_events() {
718 let mut c = Channel::new("x", Platform::Twitch, 8);
719 c.record(msg("Troll", "a"), 1);
720 c.record(msg("other", "b"), 1);
721 let mut ev = msg("troll", "");
722 ev.note = Some(Note {
723 kind: NoteKind::Sub,
724 what: "subbed".into(),
725 });
726 c.record(ev, 1);
727 c.record(msg("TROLL", "c"), 1);
728 assert_eq!(
729 c.apply(&del(proto::Target::User("troll".into()), "timed out 10m")),
730 2
731 );
732 let g = gone_after(&c);
733 assert_eq!(g[0].as_deref(), Some("timed out 10m"));
734 assert_eq!(g[1], None);
735 assert_eq!(g[2], None, "events stay");
736 assert_eq!(g[3].as_deref(), Some("timed out 10m"));
737 }
738
739 #[test]
740 fn clear_marks_everything_on_that_platform_only() {
741 let mut c = Channel::new("x", Platform::Twitch, 8);
742 c.record(msg("a", "1"), 1);
743 let mut k = msg("b", "2");
744 k.platform = Platform::Kick;
745 c.record(k, 1);
746 c.apply(&del(proto::Target::All, "cleared"));
747 let g = gone_after(&c);
748 assert_eq!(g[0].as_deref(), Some("cleared"));
749 assert_eq!(g[1], None);
750 }
751
752 #[test]
753 fn seq_counts_appends_through_eviction() {
754 let mut c = Channel::new("x", Platform::Twitch, 2);
755 for i in 0..5 {
756 c.record(msg("u", &i.to_string()), 1);
757 }
758 assert_eq!((c.seq, c.messages.len()), (5, 2));
759 }
760
761 #[test]
762 fn backfill_is_not_unread() {
763 let mut c = Channel::new("x", Platform::Twitch, 8);
764 c.backfill(vec![msg("a", "old")]);
765 assert_eq!(c.unread(), Unread::None);
766 }
767
768 fn msg(user: &str, text: &str) -> Message {
769 Message {
770 platform: Platform::Twitch,
771 user: user.into(),
772 text: text.into(),
773 color: None,
774 badges: Vec::new(),
775 id: None,
776 gone: None,
777 reply_to: None,
778 note: None,
779 extra: Default::default(),
780 emotes: None,
781 tags: Vec::new(),
782 heat: 0.0,
783 uid: None,
784 }
785 }
786
787 #[test]
788 fn insert_by_ts_lands_in_time_order_without_heat_or_unread() {
789 let at = |t: u64, text: &str| {
790 let mut m = msg("u", text);
791 m.extra.ts = t;
792 m
793 };
794 let mut ch = Channel::new("c", Platform::Twitch, 3);
795 ch.system(at(10, "a"));
796 ch.system(at(30, "c"));
797 assert!(ch.insert_by_ts(at(20, "b")));
798 let texts = |ch: &Channel| {
799 ch.messages
800 .iter()
801 .map(|m| m.text.clone())
802 .collect::<Vec<_>>()
803 };
804 assert_eq!(texts(&ch), ["a", "b", "c"]);
805 assert_eq!((ch.heat, ch.unread()), (0.0, Unread::None));
806 assert!(!ch.insert_by_ts(at(5, "old")));
808 assert!(ch.insert_by_ts(at(25, "mid")));
809 assert_eq!(texts(&ch), ["b", "mid", "c"]);
810 assert!(ch.insert_by_ts(at(40, "new")));
811 assert_eq!(texts(&ch), ["mid", "c", "new"]);
812 }
813
814 #[test]
815 fn backfill_prepends_in_order_and_dedupes_against_live() {
816 let mut ch = Channel::new("c", Platform::Twitch, 10);
817 ch.record(msg("live", "already here"), 1);
818 ch.backfill(vec![
819 msg("a", "one"),
820 msg("live", "already here"), msg("b", "two"),
822 ]);
823 let got: Vec<&str> = ch.messages.iter().map(|m| m.text.as_str()).collect();
824 assert_eq!(got, vec!["one", "two", "already here"]);
825 }
826
827 #[test]
828 fn backfill_respects_the_cap_keeping_the_newest_history() {
829 let mut ch = Channel::new("c", Platform::Twitch, 3);
830 ch.record(msg("live", "now"), 1);
831 ch.backfill((0..5).map(|i| msg("u", &format!("h{i}"))).collect());
832 assert_eq!(ch.messages.len(), 3);
833 let got: Vec<&str> = ch.messages.iter().map(|m| m.text.as_str()).collect();
834 assert_eq!(got, vec!["h3", "h4", "now"]);
836 }
837
838 #[test]
839 fn merged_tab_matches_every_sub_case_insensitively() {
840 let mut ch = Channel::new("xqc", Platform::Twitch, 10);
841 ch.extra = vec![
842 (Platform::Kick, "xqc".into()),
843 (Platform::Youtube, "Vid_123".into()),
844 ];
845 assert!(ch.merged());
846 assert!(ch.matches(Platform::Twitch, "XQC"));
847 assert!(ch.matches(Platform::Kick, "xqc"));
848 assert!(ch.matches(Platform::Youtube, "vid_123"));
849 assert!(!ch.matches(Platform::Kick, "other"));
850 assert_eq!(ch.subs().count(), 3);
851 assert!(!Channel::new("xqc", Platform::Twitch, 10).merged());
852 }
853
854 #[test]
855 fn backfill_leaves_heat_alone() {
856 let mut ch = Channel::new("c", Platform::Kick, 10);
857 ch.backfill(vec![msg("a", "x")]);
858 assert_eq!(ch.heat, 0.0);
859 assert_eq!(ch.messages[0].heat, 0.0);
860 }
861
862 #[test]
863 fn special_tabs_join_nothing_and_match_nothing() {
864 let mut c = Channel::special(TabKind::Mentions, 10);
865 assert_eq!(c.kind, TabKind::Mentions);
866 assert_eq!(c.subs().count(), 0);
867 assert!(!c.matches(Platform::Twitch, "mentions")); let mut m = msg("bob", "hey");
869 m.heat = 42.0;
870 c.deliver(m);
871 assert_eq!(c.messages[0].heat, 42.0, "row keeps its own heat");
872 assert_eq!(c.heat, 0.0);
873 assert_eq!(c.unread(), Unread::Chat);
874 c.ping();
875 assert_eq!(c.unread(), Unread::Ping);
876 }
877
878 #[test]
879 fn tab_kinds_parse_and_pin_in_order() {
880 assert_eq!(TabKind::parse("Feed"), Some(TabKind::Feed));
881 assert_eq!(TabKind::parse("xqc"), None);
882 assert_eq!(TabKind::parse("channel"), None);
883 assert!(TabKind::Mentions < TabKind::Feed && TabKind::Feed < TabKind::Whispers);
884 assert!(!TabKind::Channel.is_special());
885 }
886}