Skip to main content

heatsync_core/
lib.rs

1//! heatsync-core — the brain. protocol types, heat ramp, emote resolution, ws
2//! parsing, mock feed, and the editing model. no terminal, no async, no i/o
3//! (clocks and keystrokes are passed in). every client is a thin face over this.
4//!
5//! The editing half (`key`, `edit`, `vi`, `slash`, `clip`) lived in the tui
6//! until a second face needed it. None of it ever touched the terminal except
7//! for crossterm's key type, which `key` now replaces — a face maps its own
8//! keystrokes into `key::KeyEvent` and gets the same editor.
9
10pub 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/// where a channel's chat comes from.
35#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
36pub enum Platform {
37    Twitch,
38    Kick,
39    /// youtube live chat — a "channel" here is a live VIDEO id, not a handle.
40    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/// what a tab shows. special tabs are virtual — fed by the app, not by joining
54/// a channel — and pin left of the channel tabs in this order.
55#[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    /// every virtual tab, in pin order.
65    pub const SPECIAL: [TabKind; 3] = [TabKind::Mentions, TabKind::Feed, TabKind::Whispers];
66
67    /// the tab's label and its config / slash-command name.
68    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/// a leading label on a line: where it came from, or what kind of row it is.
89#[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    /// the source channel of a mention — dim.
98    Origin,
99    /// the line's platform, as `[T]` / `[K]` / `[Y]`, in the platform's hue.
100    Platform,
101    /// feed: a top-level post / the thread starter (magenta) / a reply.
102    Op,
103    ThreadOp,
104    Reply,
105    /// feed: the row's heat number, in its heat tier's look (text unused —
106    /// the number is read off `Message::heat` so updates land in place).
107    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    /// `[T]` / `[K]` / `[Y]`.
121    pub fn platform() -> Tag {
122        Tag::new("", TagKind::Platform)
123    }
124}
125
126impl Platform {
127    /// one-letter label for line prefixes.
128    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/// a user role badge, normalized across platforms. twitch sends
138/// what a badge is, normalized across platforms. twitch sends
139/// `"broadcaster/1,moderator/1"`, kick sends `[{name, version}]` — both parse
140/// into this one vocabulary (the extension's BADGE_SHORT set). a badge we
141/// don't know keeps its first letter instead of vanishing.
142#[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/// a badge plus its version — for a subscriber, the months tier twitch sent.
165#[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    /// a platform badge name (matched case-insensitively) + its version → our
177    /// vocabulary. an unknown name falls back to its first letter; a nameless
178    /// one is dropped.
179    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    /// single-cell glyph — badges must never widen a line by more than one
206    /// column each.
207    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/// what kind of non-chat event a [`Note`] marks. one vocabulary across
233/// platforms — a kick gift bomb and a twitch submysterygift are both `Gift`.
234#[derive(Clone, Copy, PartialEq, Eq, Debug)]
235pub enum NoteKind {
236    /// new subscription or resub (paid, prime, membership).
237    Sub,
238    /// gifted subs — single or bomb.
239    Gift,
240    /// money thrown at the stream: bits, kicks, superchats, superstickers.
241    Cheer,
242    /// raid — incoming or outgoing.
243    Raid,
244    /// channel point redemption.
245    Redeem,
246    /// stream went live.
247    Live,
248    /// stream went offline.
249    Offline,
250    /// game/category or title change.
251    Category,
252    /// announcement or generic system notice.
253    Notice,
254    /// heat spike / hype train — chat is going off.
255    Spike,
256    /// moderation: ban / timeout / chat clear.
257    Mod,
258    /// a streamer announcement, tinted by the color they picked.
259    Announce(Tint),
260    /// watch streak shared in chat.
261    Streak,
262    /// viewer milestone.
263    Milestone,
264    /// charity donation.
265    Charity,
266    /// a mod's anniversary (twitch modiversary).
267    ModAnniv,
268    /// a first-time chatter's welcome (ritual / new_chatter).
269    Ritual,
270    /// room mode change: slow, emote-only, followers-only, sub-only.
271    Mode,
272}
273
274/// an announcement's color, mapped onto the ansi palette: twitch's PRIMARY is
275/// the plain one, ORANGE reads as yellow, PURPLE as magenta.
276#[derive(Clone, Copy, PartialEq, Eq, Debug)]
277pub enum Tint {
278    Plain,
279    Blue,
280    Green,
281    Yellow,
282    Magenta,
283}
284
285impl Tint {
286    /// twitch's `msg-param-color` → tint; anything unknown stays plain.
287    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/// per-line extras that change how a chat line looks, grouped so a new face
299/// only has to carry one field.
300#[derive(Clone, Debug, Default, PartialEq)]
301pub struct Extra {
302    /// the text of the message being replied to (sanitized, may be empty).
303    pub reply_text: String,
304    /// `/me` — rendered in the user's color, italic.
305    pub action: bool,
306    /// the user's first message in this channel.
307    pub first: bool,
308    /// redeemed "highlight my message" — rendered reverse-video.
309    pub highlight: bool,
310    /// wall-clock seconds when the line landed (0 = unknown) — for `HH:MM`.
311    pub ts: u64,
312}
313
314/// a non-chat event shown inline in the chat flow. `what` is the human
315/// headline ("resubscribed for 14 months", "raiding with 500 viewers") — the
316/// message's `user` is the actor and its `text` carries any attached user
317/// message (a resub message, a redemption input), which renders like chat.
318#[derive(Clone, Debug, PartialEq)]
319pub struct Note {
320    pub kind: NoteKind,
321    pub what: String,
322}
323
324/// one chat line. `heat` is the channel's heat snapshotted at arrival, so a
325/// message sent during a spike stays warm in scrollback. `color` is the user's
326/// chat color (hex) if the platform sent one.
327#[derive(Clone, Debug)]
328pub struct Message {
329    /// where this line came from — a merged tab interleaves platforms, and
330    /// each line keeps its origin for display.
331    pub platform: Platform,
332    pub user: String,
333    pub text: String,
334    pub color: Option<String>,
335    pub badges: Vec<Badge>,
336    /// username this message replies to, when the platform sent one.
337    pub reply_to: Option<String>,
338    /// the platform's message id, when the relay sent one.
339    pub id: Option<String>,
340    /// set when a mod removed this line: the marker ("deleted", "timed out
341    /// 10m"…). the line stays in scrollback, struck through.
342    pub gone: Option<String>,
343    /// set when this line is an event (sub, raid, redemption, live…) rather
344    /// than plain chat — it renders as an inline notice.
345    pub note: Option<Note>,
346    /// the sender's heatsync paint-space id (twitch user id, `kick_<id>`,
347    /// `yt_<UC id>`), when the relay sent one — keys name paints and colors.
348    pub uid: Option<String>,
349    pub extra: Extra,
350    /// this line's own emote refs (see [`emote::segments_with`]), built once
351    /// when the line lands; None for lines that carry none (history, system).
352    pub emotes: Option<std::sync::Arc<emote::EmoteSet>>,
353    /// leading labels (mention origin, feed `[OP]`, `[whisper]`…); empty on
354    /// ordinary chat.
355    pub tags: Vec<Tag>,
356    pub heat: f64,
357}
358
359/// a channel's pinned message — one line in a bar above chat.
360#[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    /// the per-message ref set, empty when the line carries none.
369    pub fn refs(&self) -> &emote::EmoteSet {
370        self.emotes.as_deref().unwrap_or(emote::EmoteSet::none())
371    }
372}
373
374/// live state for a single channel. bounded ring buffer — a raid can flood
375/// forever and we never grow past `cap` (mele is 8GB; resource-conscious).
376/// heat is a decaying counter driven by message arrivals.
377#[derive(Clone, Debug)]
378pub struct Channel {
379    /// a real channel, or one of the virtual tabs (see [`TabKind`]).
380    pub kind: TabKind,
381    pub name: String,
382    /// primary source — sends target it, and it leads the tab label.
383    pub platform: Platform,
384    /// further sources merged into this tab (a `+`-joined channel). their
385    /// lines interleave into the one ring in arrival order.
386    pub extra: Vec<(Platform, String)>,
387    /// the unsaved "stream i'm looking at" tab: one slot, never persisted or
388    /// shared, promoted to a saved tab by keeping it.
389    pub temp: bool,
390    pub heat: f64,
391    /// wall-clock ms of the last heat update — decay is computed against it.
392    pub last_ms: u64,
393    pub messages: VecDeque<Message>,
394    /// live lines recorded since the tab was last on screen.
395    unread: u32,
396    /// one of them pinged the user.
397    pinged: bool,
398    /// one of them was a stream event.
399    evented: bool,
400    /// the channel's pinned message, while it has one.
401    pin: Option<Pin>,
402    /// id of the pin the user dismissed — it stays hidden until a new one lands.
403    pin_dismissed: Option<String>,
404    /// lines ever appended at the live end — a monotonic clock for "how many
405    /// arrived since", unaffected by the ring evicting old ones.
406    pub seq: u64,
407    cap: usize,
408}
409
410/// what a tab has waiting, for its color: nothing, chat, a stream event, or a ping.
411#[derive(Clone, Copy, PartialEq, Eq, Debug)]
412pub enum Unread {
413    None,
414    Chat,
415    /// a stream event (went live / offline / game switch) landed unseen.
416    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    /// a virtual tab. it joins nothing: `subs()` is empty, so no line ever
442    /// matches it and no part / emote / history call is made for it.
443    pub fn special(kind: TabKind, cap: usize) -> Channel {
444        Channel {
445            kind,
446            ..Channel::new(kind.name(), Platform::Twitch, cap)
447        }
448    }
449
450    /// append a line to a virtual tab: counts as unread, but keeps the row's
451    /// own heat (a feed row's heat is the server's, not ours).
452    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    /// the tab's unread state.
462    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    /// flag the most recent line as a ping (caller knows who "me" is).
475    pub fn ping(&mut self) {
476        self.pinged = true;
477    }
478
479    /// a pin arrived (`Some`) or was cleared (`None`).
480    pub fn set_pin(&mut self, pin: Option<Pin>) {
481        self.pin = pin;
482    }
483
484    /// the pin to show, unless the user dismissed this very one.
485    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    /// hide the current pin (a newer one will show again). false = none shown.
492    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    /// flag the tab as holding an unseen stream event.
502    pub fn flag_event(&mut self) {
503        self.evented = true;
504    }
505
506    /// add a client-side line (a send rejection, a connection notice): keeps the
507    /// cap, but touches neither heat nor unread — it is not chat.
508    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    /// slot a line in by its own time (`extra.ts`, seconds) — a replayed or
517    /// back-dated event, not news: no heat, no unread, no ping. it lands after
518    /// the last line that is not newer than it. false = it would sit older than
519    /// everything in a full ring, so it is dropped.
520    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    /// a mod removed lines: mark every matching chat line (events are never
539    /// struck). returns how many were marked. the caller has already routed
540    /// `d` to this tab; platform is checked per line (merged tabs).
541    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    /// the tab is on screen — everything it holds is now seen.
561    pub fn mark_seen(&mut self) {
562        self.unread = 0;
563        self.pinged = false;
564        self.evented = false;
565    }
566
567    /// every source feeding this tab, primary first.
568    pub fn subs(&self) -> impl Iterator<Item = (Platform, &str)> {
569        // a virtual tab has no sources — it must never match a line or be parted.
570        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    /// does a line for (platform, channel) belong to this tab?
576    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    /// is this a merged (multi-source) tab?
582    pub fn merged(&self) -> bool {
583        !self.extra.is_empty()
584    }
585
586    /// advance heat decay to `now_ms` without adding anything (idle cooling).
587    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    /// seed scrollback with archived history: prepended oldest-outward, deduped
598    /// against lines already buffered (the live feed may overlap the archive's
599    /// tail), heat untouched — history is cold by definition. `msgs` is
600    /// chronological (oldest first); the cap still bounds the buffer.
601    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    /// record a message: decay to now, add one increment, snapshot heat onto
618    /// the line, then store it (evicting the oldest past the cap).
619    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        // full ring: older than everything is dropped, anything else evicts the oldest
807        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"), // overlap with the live tail
821            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        // newest history survives, oldest is dropped
835        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")); // not even its own name
868        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}