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