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