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 Ritual,
268 Mode,
270}
271
272#[derive(Clone, Copy, PartialEq, Eq, Debug)]
275pub enum Tint {
276 Plain,
277 Blue,
278 Green,
279 Yellow,
280 Magenta,
281}
282
283impl Tint {
284 pub fn from_name(s: &str) -> Tint {
286 match s.to_ascii_uppercase().as_str() {
287 "BLUE" => Tint::Blue,
288 "GREEN" => Tint::Green,
289 "ORANGE" => Tint::Yellow,
290 "PURPLE" => Tint::Magenta,
291 _ => Tint::Plain,
292 }
293 }
294}
295
296#[derive(Clone, Debug, Default, PartialEq)]
299pub struct Extra {
300 pub reply_text: String,
302 pub action: bool,
304 pub first: bool,
306 pub highlight: bool,
308 pub ts: u64,
310}
311
312#[derive(Clone, Debug, PartialEq)]
317pub struct Note {
318 pub kind: NoteKind,
319 pub what: String,
320}
321
322#[derive(Clone, Debug)]
326pub struct Message {
327 pub platform: Platform,
330 pub user: String,
331 pub text: String,
332 pub color: Option<String>,
333 pub badges: Vec<Badge>,
334 pub reply_to: Option<String>,
336 pub id: Option<String>,
338 pub gone: Option<String>,
341 pub note: Option<Note>,
344 pub uid: Option<String>,
347 pub extra: Extra,
348 pub emotes: Option<std::sync::Arc<emote::EmoteSet>>,
351 pub tags: Vec<Tag>,
354 pub heat: f64,
355}
356
357#[derive(Clone, Debug, PartialEq, Eq)]
359pub struct Pin {
360 pub id: String,
361 pub user: String,
362 pub text: String,
363}
364
365impl Message {
366 pub fn refs(&self) -> &emote::EmoteSet {
368 self.emotes.as_deref().unwrap_or(emote::EmoteSet::none())
369 }
370}
371
372#[derive(Clone, Debug)]
376pub struct Channel {
377 pub kind: TabKind,
379 pub name: String,
380 pub platform: Platform,
382 pub extra: Vec<(Platform, String)>,
385 pub temp: bool,
388 pub heat: f64,
389 pub last_ms: u64,
391 pub messages: VecDeque<Message>,
392 unread: u32,
394 pinged: bool,
396 evented: bool,
398 pin: Option<Pin>,
400 pin_dismissed: Option<String>,
402 pub seq: u64,
405 cap: usize,
406}
407
408#[derive(Clone, Copy, PartialEq, Eq, Debug)]
410pub enum Unread {
411 None,
412 Chat,
413 Event,
415 Ping,
416}
417
418impl Channel {
419 pub fn new(name: &str, platform: Platform, cap: usize) -> Channel {
420 Channel {
421 kind: TabKind::Channel,
422 name: name.to_string(),
423 platform,
424 extra: Vec::new(),
425 temp: false,
426 heat: 0.0,
427 last_ms: 0,
428 messages: VecDeque::with_capacity(cap),
429 unread: 0,
430 pinged: false,
431 evented: false,
432 pin: None,
433 pin_dismissed: None,
434 seq: 0,
435 cap,
436 }
437 }
438
439 pub fn special(kind: TabKind, cap: usize) -> Channel {
442 Channel {
443 kind,
444 ..Channel::new(kind.name(), Platform::Twitch, cap)
445 }
446 }
447
448 pub fn deliver(&mut self, msg: Message) {
451 if self.messages.len() == self.cap {
452 self.messages.pop_front();
453 }
454 self.messages.push_back(msg);
455 self.seq += 1;
456 self.unread = self.unread.saturating_add(1);
457 }
458
459 pub fn unread(&self) -> Unread {
461 if self.pinged {
462 Unread::Ping
463 } else if self.evented {
464 Unread::Event
465 } else if self.unread > 0 {
466 Unread::Chat
467 } else {
468 Unread::None
469 }
470 }
471
472 pub fn ping(&mut self) {
474 self.pinged = true;
475 }
476
477 pub fn set_pin(&mut self, pin: Option<Pin>) {
479 self.pin = pin;
480 }
481
482 pub fn visible_pin(&self) -> Option<&Pin> {
484 self.pin
485 .as_ref()
486 .filter(|p| self.pin_dismissed.as_deref() != Some(p.id.as_str()))
487 }
488
489 pub fn dismiss_pin(&mut self) -> bool {
491 let id = self.visible_pin().map(|p| p.id.clone());
492 let some = id.is_some();
493 if some {
494 self.pin_dismissed = id;
495 }
496 some
497 }
498
499 pub fn flag_event(&mut self) {
501 self.evented = true;
502 }
503
504 pub fn system(&mut self, msg: Message) {
507 if self.messages.len() == self.cap {
508 self.messages.pop_front();
509 }
510 self.messages.push_back(msg);
511 self.seq += 1;
512 }
513
514 pub fn insert_by_ts(&mut self, msg: Message) -> bool {
519 let at = self
520 .messages
521 .iter()
522 .rposition(|m| m.extra.ts <= msg.extra.ts)
523 .map_or(0, |i| i + 1);
524 if self.messages.len() == self.cap {
525 if at == 0 {
526 return false;
527 }
528 self.messages.pop_front();
529 self.messages.insert(at - 1, msg);
530 } else {
531 self.messages.insert(at, msg);
532 }
533 true
534 }
535
536 pub fn apply(&mut self, d: &proto::Delete) -> usize {
540 let mut n = 0;
541 for m in self.messages.iter_mut() {
542 if m.platform != d.platform || m.note.is_some() || m.gone.is_some() {
543 continue;
544 }
545 let hit = match &d.target {
546 proto::Target::Msg(id) => m.id.as_deref() == Some(id.as_str()),
547 proto::Target::User(u) => !m.user.is_empty() && m.user.eq_ignore_ascii_case(u),
548 proto::Target::All => true,
549 };
550 if hit {
551 m.gone = Some(d.label.clone());
552 n += 1;
553 }
554 }
555 n
556 }
557
558 pub fn mark_seen(&mut self) {
560 self.unread = 0;
561 self.pinged = false;
562 self.evented = false;
563 }
564
565 pub fn subs(&self) -> impl Iterator<Item = (Platform, &str)> {
567 let own = (self.kind == TabKind::Channel).then_some((self.platform, self.name.as_str()));
569 own.into_iter()
570 .chain(self.extra.iter().map(|(p, n)| (*p, n.as_str())))
571 }
572
573 pub fn matches(&self, platform: Platform, name: &str) -> bool {
575 self.subs()
576 .any(|(p, n)| p == platform && n.eq_ignore_ascii_case(name))
577 }
578
579 pub fn merged(&self) -> bool {
581 !self.extra.is_empty()
582 }
583
584 pub fn cool(&mut self, now_ms: u64) {
586 if self.last_ms == 0 {
587 self.last_ms = now_ms;
588 return;
589 }
590 let dt = now_ms.saturating_sub(self.last_ms) as f64;
591 self.heat = heat::decay(self.heat, dt);
592 self.last_ms = now_ms;
593 }
594
595 pub fn backfill(&mut self, msgs: Vec<Message>) {
600 for m in msgs.into_iter().rev() {
601 if self.messages.len() == self.cap {
602 break;
603 }
604 if self
605 .messages
606 .iter()
607 .any(|e| e.user == m.user && e.text == m.text)
608 {
609 continue;
610 }
611 self.messages.push_front(m);
612 }
613 }
614
615 pub fn record(&mut self, mut msg: Message, now_ms: u64) {
618 self.cool(now_ms);
619 self.heat += heat::INCREMENT;
620 msg.heat = self.heat;
621 if self.messages.len() == self.cap {
622 self.messages.pop_front();
623 }
624 self.messages.push_back(msg);
625 self.seq += 1;
626 self.unread = self.unread.saturating_add(1);
627 }
628}
629
630#[cfg(test)]
631mod channel_tests {
632 use super::*;
633
634 #[test]
635 fn unread_goes_none_chat_ping_and_clears_when_seen() {
636 let mut c = Channel::new("x", Platform::Twitch, 8);
637 assert_eq!(c.unread(), Unread::None);
638 c.record(msg("a", "hi"), 1);
639 assert_eq!(c.unread(), Unread::Chat);
640 c.flag_event();
641 assert_eq!(c.unread(), Unread::Event);
642 c.record(msg("a", "again"), 1);
643 assert_eq!(
644 c.unread(),
645 Unread::Event,
646 "chat does not downgrade an event"
647 );
648 c.ping();
649 assert_eq!(c.unread(), Unread::Ping);
650 c.record(msg("b", "yo"), 2);
651 assert_eq!(
652 c.unread(),
653 Unread::Ping,
654 "a later plain line does not downgrade a ping"
655 );
656 c.mark_seen();
657 assert_eq!(c.unread(), Unread::None);
658 }
659
660 #[test]
661 fn a_dismissed_pin_stays_hidden_until_a_new_one_lands() {
662 let pin = |id: &str| Pin {
663 id: id.into(),
664 user: "u".into(),
665 text: "t".into(),
666 };
667 let mut c = Channel::new("x", Platform::Twitch, 8);
668 assert!(!c.dismiss_pin(), "nothing to dismiss");
669 c.set_pin(Some(pin("a")));
670 assert_eq!(c.visible_pin().map(|p| p.id.as_str()), Some("a"));
671 assert!(c.dismiss_pin());
672 assert!(c.visible_pin().is_none());
673 c.set_pin(Some(pin("a")));
674 assert!(c.visible_pin().is_none(), "same pin re-sent stays hidden");
675 c.set_pin(Some(pin("b")));
676 assert!(c.visible_pin().is_some());
677 c.set_pin(None);
678 assert!(c.visible_pin().is_none());
679 }
680
681 fn gone_after(c: &Channel) -> Vec<Option<String>> {
682 c.messages.iter().map(|m| m.gone.clone()).collect()
683 }
684
685 fn del(target: proto::Target, label: &str) -> proto::Delete {
686 proto::Delete {
687 platform: Platform::Twitch,
688 channel: "x".into(),
689 target,
690 label: label.into(),
691 }
692 }
693
694 #[test]
695 fn delete_by_id_marks_only_that_line() {
696 let mut c = Channel::new("x", Platform::Twitch, 8);
697 for (i, t) in ["a", "b", "c"].iter().enumerate() {
698 let mut m = msg("u", t);
699 m.id = Some(format!("id{i}"));
700 c.record(m, 1);
701 }
702 assert_eq!(
703 c.apply(&del(proto::Target::Msg("id1".into()), "deleted")),
704 1
705 );
706 assert_eq!(gone_after(&c), vec![None, Some("deleted".into()), None]);
707 assert_eq!(
708 c.apply(&del(proto::Target::Msg("id1".into()), "deleted")),
709 0,
710 "already marked"
711 );
712 }
713
714 #[test]
715 fn timeout_marks_all_of_a_users_chat_lines_not_events() {
716 let mut c = Channel::new("x", Platform::Twitch, 8);
717 c.record(msg("Troll", "a"), 1);
718 c.record(msg("other", "b"), 1);
719 let mut ev = msg("troll", "");
720 ev.note = Some(Note {
721 kind: NoteKind::Sub,
722 what: "subbed".into(),
723 });
724 c.record(ev, 1);
725 c.record(msg("TROLL", "c"), 1);
726 assert_eq!(
727 c.apply(&del(proto::Target::User("troll".into()), "timed out 10m")),
728 2
729 );
730 let g = gone_after(&c);
731 assert_eq!(g[0].as_deref(), Some("timed out 10m"));
732 assert_eq!(g[1], None);
733 assert_eq!(g[2], None, "events stay");
734 assert_eq!(g[3].as_deref(), Some("timed out 10m"));
735 }
736
737 #[test]
738 fn clear_marks_everything_on_that_platform_only() {
739 let mut c = Channel::new("x", Platform::Twitch, 8);
740 c.record(msg("a", "1"), 1);
741 let mut k = msg("b", "2");
742 k.platform = Platform::Kick;
743 c.record(k, 1);
744 c.apply(&del(proto::Target::All, "cleared"));
745 let g = gone_after(&c);
746 assert_eq!(g[0].as_deref(), Some("cleared"));
747 assert_eq!(g[1], None);
748 }
749
750 #[test]
751 fn seq_counts_appends_through_eviction() {
752 let mut c = Channel::new("x", Platform::Twitch, 2);
753 for i in 0..5 {
754 c.record(msg("u", &i.to_string()), 1);
755 }
756 assert_eq!((c.seq, c.messages.len()), (5, 2));
757 }
758
759 #[test]
760 fn backfill_is_not_unread() {
761 let mut c = Channel::new("x", Platform::Twitch, 8);
762 c.backfill(vec![msg("a", "old")]);
763 assert_eq!(c.unread(), Unread::None);
764 }
765
766 fn msg(user: &str, text: &str) -> Message {
767 Message {
768 platform: Platform::Twitch,
769 user: user.into(),
770 text: text.into(),
771 color: None,
772 badges: Vec::new(),
773 id: None,
774 gone: None,
775 reply_to: None,
776 note: None,
777 extra: Default::default(),
778 emotes: None,
779 tags: Vec::new(),
780 heat: 0.0,
781 uid: None,
782 }
783 }
784
785 #[test]
786 fn insert_by_ts_lands_in_time_order_without_heat_or_unread() {
787 let at = |t: u64, text: &str| {
788 let mut m = msg("u", text);
789 m.extra.ts = t;
790 m
791 };
792 let mut ch = Channel::new("c", Platform::Twitch, 3);
793 ch.system(at(10, "a"));
794 ch.system(at(30, "c"));
795 assert!(ch.insert_by_ts(at(20, "b")));
796 let texts = |ch: &Channel| {
797 ch.messages
798 .iter()
799 .map(|m| m.text.clone())
800 .collect::<Vec<_>>()
801 };
802 assert_eq!(texts(&ch), ["a", "b", "c"]);
803 assert_eq!((ch.heat, ch.unread()), (0.0, Unread::None));
804 assert!(!ch.insert_by_ts(at(5, "old")));
806 assert!(ch.insert_by_ts(at(25, "mid")));
807 assert_eq!(texts(&ch), ["b", "mid", "c"]);
808 assert!(ch.insert_by_ts(at(40, "new")));
809 assert_eq!(texts(&ch), ["mid", "c", "new"]);
810 }
811
812 #[test]
813 fn backfill_prepends_in_order_and_dedupes_against_live() {
814 let mut ch = Channel::new("c", Platform::Twitch, 10);
815 ch.record(msg("live", "already here"), 1);
816 ch.backfill(vec![
817 msg("a", "one"),
818 msg("live", "already here"), msg("b", "two"),
820 ]);
821 let got: Vec<&str> = ch.messages.iter().map(|m| m.text.as_str()).collect();
822 assert_eq!(got, vec!["one", "two", "already here"]);
823 }
824
825 #[test]
826 fn backfill_respects_the_cap_keeping_the_newest_history() {
827 let mut ch = Channel::new("c", Platform::Twitch, 3);
828 ch.record(msg("live", "now"), 1);
829 ch.backfill((0..5).map(|i| msg("u", &format!("h{i}"))).collect());
830 assert_eq!(ch.messages.len(), 3);
831 let got: Vec<&str> = ch.messages.iter().map(|m| m.text.as_str()).collect();
832 assert_eq!(got, vec!["h3", "h4", "now"]);
834 }
835
836 #[test]
837 fn merged_tab_matches_every_sub_case_insensitively() {
838 let mut ch = Channel::new("xqc", Platform::Twitch, 10);
839 ch.extra = vec![
840 (Platform::Kick, "xqc".into()),
841 (Platform::Youtube, "Vid_123".into()),
842 ];
843 assert!(ch.merged());
844 assert!(ch.matches(Platform::Twitch, "XQC"));
845 assert!(ch.matches(Platform::Kick, "xqc"));
846 assert!(ch.matches(Platform::Youtube, "vid_123"));
847 assert!(!ch.matches(Platform::Kick, "other"));
848 assert_eq!(ch.subs().count(), 3);
849 assert!(!Channel::new("xqc", Platform::Twitch, 10).merged());
850 }
851
852 #[test]
853 fn backfill_leaves_heat_alone() {
854 let mut ch = Channel::new("c", Platform::Kick, 10);
855 ch.backfill(vec![msg("a", "x")]);
856 assert_eq!(ch.heat, 0.0);
857 assert_eq!(ch.messages[0].heat, 0.0);
858 }
859
860 #[test]
861 fn special_tabs_join_nothing_and_match_nothing() {
862 let mut c = Channel::special(TabKind::Mentions, 10);
863 assert_eq!(c.kind, TabKind::Mentions);
864 assert_eq!(c.subs().count(), 0);
865 assert!(!c.matches(Platform::Twitch, "mentions")); let mut m = msg("bob", "hey");
867 m.heat = 42.0;
868 c.deliver(m);
869 assert_eq!(c.messages[0].heat, 42.0, "row keeps its own heat");
870 assert_eq!(c.heat, 0.0);
871 assert_eq!(c.unread(), Unread::Chat);
872 c.ping();
873 assert_eq!(c.unread(), Unread::Ping);
874 }
875
876 #[test]
877 fn tab_kinds_parse_and_pin_in_order() {
878 assert_eq!(TabKind::parse("Feed"), Some(TabKind::Feed));
879 assert_eq!(TabKind::parse("xqc"), None);
880 assert_eq!(TabKind::parse("channel"), None);
881 assert!(TabKind::Mentions < TabKind::Feed && TabKind::Feed < TabKind::Whispers);
882 assert!(!TabKind::Channel.is_special());
883 }
884}