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 first-time chatter's welcome (ritual / new_chatter).
267    Ritual,
268    /// room mode change: slow, emote-only, followers-only, sub-only.
269    Mode,
270}
271
272/// an announcement's color, mapped onto the ansi palette: twitch's PRIMARY is
273/// the plain one, ORANGE reads as yellow, PURPLE as magenta.
274#[derive(Clone, Copy, PartialEq, Eq, Debug)]
275pub enum Tint {
276    Plain,
277    Blue,
278    Green,
279    Yellow,
280    Magenta,
281}
282
283impl Tint {
284    /// twitch's `msg-param-color` → tint; anything unknown stays plain.
285    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/// per-line extras that change how a chat line looks, grouped so a new face
297/// only has to carry one field.
298#[derive(Clone, Debug, Default, PartialEq)]
299pub struct Extra {
300    /// the text of the message being replied to (sanitized, may be empty).
301    pub reply_text: String,
302    /// `/me` — rendered in the user's color, italic.
303    pub action: bool,
304    /// the user's first message in this channel.
305    pub first: bool,
306    /// redeemed "highlight my message" — rendered reverse-video.
307    pub highlight: bool,
308    /// wall-clock seconds when the line landed (0 = unknown) — for `HH:MM`.
309    pub ts: u64,
310}
311
312/// a non-chat event shown inline in the chat flow. `what` is the human
313/// headline ("resubscribed for 14 months", "raiding with 500 viewers") — the
314/// message's `user` is the actor and its `text` carries any attached user
315/// message (a resub message, a redemption input), which renders like chat.
316#[derive(Clone, Debug, PartialEq)]
317pub struct Note {
318    pub kind: NoteKind,
319    pub what: String,
320}
321
322/// one chat line. `heat` is the channel's heat snapshotted at arrival, so a
323/// message sent during a spike stays warm in scrollback. `color` is the user's
324/// chat color (hex) if the platform sent one.
325#[derive(Clone, Debug)]
326pub struct Message {
327    /// where this line came from — a merged tab interleaves platforms, and
328    /// each line keeps its origin for display.
329    pub platform: Platform,
330    pub user: String,
331    pub text: String,
332    pub color: Option<String>,
333    pub badges: Vec<Badge>,
334    /// username this message replies to, when the platform sent one.
335    pub reply_to: Option<String>,
336    /// the platform's message id, when the relay sent one.
337    pub id: Option<String>,
338    /// set when a mod removed this line: the marker ("deleted", "timed out
339    /// 10m"…). the line stays in scrollback, struck through.
340    pub gone: Option<String>,
341    /// set when this line is an event (sub, raid, redemption, live…) rather
342    /// than plain chat — it renders as an inline notice.
343    pub note: Option<Note>,
344    /// the sender's heatsync paint-space id (twitch user id, `kick_<id>`,
345    /// `yt_<UC id>`), when the relay sent one — keys name paints and colors.
346    pub uid: Option<String>,
347    pub extra: Extra,
348    /// this line's own emote refs (see [`emote::segments_with`]), built once
349    /// when the line lands; None for lines that carry none (history, system).
350    pub emotes: Option<std::sync::Arc<emote::EmoteSet>>,
351    /// leading labels (mention origin, feed `[OP]`, `[whisper]`…); empty on
352    /// ordinary chat.
353    pub tags: Vec<Tag>,
354    pub heat: f64,
355}
356
357/// a channel's pinned message — one line in a bar above chat.
358#[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    /// the per-message ref set, empty when the line carries none.
367    pub fn refs(&self) -> &emote::EmoteSet {
368        self.emotes.as_deref().unwrap_or(emote::EmoteSet::none())
369    }
370}
371
372/// live state for a single channel. bounded ring buffer — a raid can flood
373/// forever and we never grow past `cap` (mele is 8GB; resource-conscious).
374/// heat is a decaying counter driven by message arrivals.
375#[derive(Clone, Debug)]
376pub struct Channel {
377    /// a real channel, or one of the virtual tabs (see [`TabKind`]).
378    pub kind: TabKind,
379    pub name: String,
380    /// primary source — sends target it, and it leads the tab label.
381    pub platform: Platform,
382    /// further sources merged into this tab (a `+`-joined channel). their
383    /// lines interleave into the one ring in arrival order.
384    pub extra: Vec<(Platform, String)>,
385    /// the unsaved "stream i'm looking at" tab: one slot, never persisted or
386    /// shared, promoted to a saved tab by keeping it.
387    pub temp: bool,
388    pub heat: f64,
389    /// wall-clock ms of the last heat update — decay is computed against it.
390    pub last_ms: u64,
391    pub messages: VecDeque<Message>,
392    /// live lines recorded since the tab was last on screen.
393    unread: u32,
394    /// one of them pinged the user.
395    pinged: bool,
396    /// one of them was a stream event.
397    evented: bool,
398    /// the channel's pinned message, while it has one.
399    pin: Option<Pin>,
400    /// id of the pin the user dismissed — it stays hidden until a new one lands.
401    pin_dismissed: Option<String>,
402    /// lines ever appended at the live end — a monotonic clock for "how many
403    /// arrived since", unaffected by the ring evicting old ones.
404    pub seq: u64,
405    cap: usize,
406}
407
408/// what a tab has waiting, for its color: nothing, chat, a stream event, or a ping.
409#[derive(Clone, Copy, PartialEq, Eq, Debug)]
410pub enum Unread {
411    None,
412    Chat,
413    /// a stream event (went live / offline / game switch) landed unseen.
414    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    /// a virtual tab. it joins nothing: `subs()` is empty, so no line ever
440    /// matches it and no part / emote / history call is made for it.
441    pub fn special(kind: TabKind, cap: usize) -> Channel {
442        Channel {
443            kind,
444            ..Channel::new(kind.name(), Platform::Twitch, cap)
445        }
446    }
447
448    /// append a line to a virtual tab: counts as unread, but keeps the row's
449    /// own heat (a feed row's heat is the server's, not ours).
450    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    /// the tab's unread state.
460    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    /// flag the most recent line as a ping (caller knows who "me" is).
473    pub fn ping(&mut self) {
474        self.pinged = true;
475    }
476
477    /// a pin arrived (`Some`) or was cleared (`None`).
478    pub fn set_pin(&mut self, pin: Option<Pin>) {
479        self.pin = pin;
480    }
481
482    /// the pin to show, unless the user dismissed this very one.
483    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    /// hide the current pin (a newer one will show again). false = none shown.
490    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    /// flag the tab as holding an unseen stream event.
500    pub fn flag_event(&mut self) {
501        self.evented = true;
502    }
503
504    /// add a client-side line (a send rejection, a connection notice): keeps the
505    /// cap, but touches neither heat nor unread — it is not chat.
506    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    /// slot a line in by its own time (`extra.ts`, seconds) — a replayed or
515    /// back-dated event, not news: no heat, no unread, no ping. it lands after
516    /// the last line that is not newer than it. false = it would sit older than
517    /// everything in a full ring, so it is dropped.
518    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    /// a mod removed lines: mark every matching chat line (events are never
537    /// struck). returns how many were marked. the caller has already routed
538    /// `d` to this tab; platform is checked per line (merged tabs).
539    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    /// the tab is on screen — everything it holds is now seen.
559    pub fn mark_seen(&mut self) {
560        self.unread = 0;
561        self.pinged = false;
562        self.evented = false;
563    }
564
565    /// every source feeding this tab, primary first.
566    pub fn subs(&self) -> impl Iterator<Item = (Platform, &str)> {
567        // a virtual tab has no sources — it must never match a line or be parted.
568        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    /// does a line for (platform, channel) belong to this tab?
574    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    /// is this a merged (multi-source) tab?
580    pub fn merged(&self) -> bool {
581        !self.extra.is_empty()
582    }
583
584    /// advance heat decay to `now_ms` without adding anything (idle cooling).
585    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    /// seed scrollback with archived history: prepended oldest-outward, deduped
596    /// against lines already buffered (the live feed may overlap the archive's
597    /// tail), heat untouched — history is cold by definition. `msgs` is
598    /// chronological (oldest first); the cap still bounds the buffer.
599    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    /// record a message: decay to now, add one increment, snapshot heat onto
616    /// the line, then store it (evicting the oldest past the cap).
617    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        // full ring: older than everything is dropped, anything else evicts the oldest
805        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"), // overlap with the live tail
819            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        // newest history survives, oldest is dropped
833        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")); // not even its own name
866        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}