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 blockid;
12pub mod clip;
13pub mod complete;
14pub mod dupe;
15pub mod edit;
16pub mod emote;
17pub mod heat;
18pub mod hsbadge;
19pub mod i18n;
20pub mod iso;
21pub mod key;
22pub mod layout;
23pub mod mock;
24pub mod paint;
25pub mod proto;
26pub mod room;
27pub mod sanitize;
28pub mod scroll;
29pub mod slash;
30pub mod social;
31pub mod streamev;
32
33use std::collections::{HashSet, VecDeque};
34
35/// where a channel's chat comes from.
36#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
37pub enum Platform {
38    Twitch,
39    Kick,
40    /// youtube live chat — a "channel" here is a live VIDEO id, not a handle.
41    Youtube,
42}
43
44impl Platform {
45    pub fn tag(self) -> &'static str {
46        match self {
47            Platform::Twitch => "tw",
48            Platform::Kick => "kk",
49            Platform::Youtube => "yt",
50        }
51    }
52}
53
54/// what a tab shows. special tabs are virtual — fed by the app, not by joining
55/// a channel — and pin left of the channel tabs in this order.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub enum TabKind {
58    Channel,
59    Mentions,
60    Feed,
61    Whispers,
62}
63
64impl TabKind {
65    /// every virtual tab, in pin order.
66    pub const SPECIAL: [TabKind; 3] = [TabKind::Mentions, TabKind::Feed, TabKind::Whispers];
67
68    /// the tab's label and its config / slash-command name.
69    pub fn name(self) -> &'static str {
70        match self {
71            TabKind::Channel => "channel",
72            TabKind::Mentions => "mentions",
73            TabKind::Feed => "feed",
74            TabKind::Whispers => "whispers",
75        }
76    }
77
78    pub fn parse(s: &str) -> Option<TabKind> {
79        TabKind::SPECIAL
80            .into_iter()
81            .find(|k| k.name().eq_ignore_ascii_case(s.trim()))
82    }
83
84    pub fn is_special(self) -> bool {
85        self != TabKind::Channel
86    }
87}
88
89/// a leading label on a line: where it came from, or what kind of row it is.
90#[derive(Clone, Debug, PartialEq)]
91pub struct Tag {
92    pub text: String,
93    pub kind: TagKind,
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum TagKind {
98    /// the source channel of a mention — dim.
99    Origin,
100    /// the line's platform, as `[T]` / `[K]` / `[Y]`, in the platform's hue.
101    Platform,
102    /// feed: a top-level post / the thread starter (magenta) / a reply.
103    Op,
104    ThreadOp,
105    Reply,
106    /// feed: the row's heat number, in its heat tier's look (text unused —
107    /// the number is read off `Message::heat` so updates land in place).
108    Heat,
109    Whisper,
110    Dm,
111}
112
113impl Tag {
114    pub fn new(text: &str, kind: TagKind) -> Tag {
115        Tag {
116            text: text.to_string(),
117            kind,
118        }
119    }
120
121    /// `[T]` / `[K]` / `[Y]`.
122    pub fn platform() -> Tag {
123        Tag::new("", TagKind::Platform)
124    }
125}
126
127impl Platform {
128    /// one-letter label for line prefixes.
129    pub fn letter(self) -> char {
130        match self {
131            Platform::Twitch => 'T',
132            Platform::Kick => 'K',
133            Platform::Youtube => 'Y',
134        }
135    }
136}
137
138/// a user role badge, normalized across platforms. twitch sends
139/// what a badge is, normalized across platforms. twitch sends
140/// `"broadcaster/1,moderator/1"`, kick sends `[{name, version}]` — both parse
141/// into this one vocabulary (the extension's BADGE_SHORT set). a badge we
142/// don't know keeps its first letter instead of vanishing.
143#[derive(Clone, Copy, PartialEq, Eq, Debug)]
144pub enum BadgeKind {
145    Broadcaster,
146    Moderator,
147    Vip,
148    Subscriber,
149    Founder,
150    Staff,
151    GlobalMod,
152    Verified,
153    Og,
154    Member,
155    Premium,
156    Turbo,
157    Bits,
158    Gifter,
159    Predictions,
160    Hype,
161    Artist,
162    Other(char),
163}
164
165/// a badge plus its version — for a subscriber, the months tier twitch sent.
166#[derive(Clone, Copy, PartialEq, Eq, Debug)]
167pub struct Badge {
168    pub kind: BadgeKind,
169    pub ver: u16,
170}
171
172impl Badge {
173    pub fn new(kind: BadgeKind) -> Badge {
174        Badge { kind, ver: 0 }
175    }
176
177    /// a platform badge name (matched case-insensitively) + its version → our
178    /// vocabulary. an unknown name falls back to its first letter; a nameless
179    /// one is dropped.
180    pub fn from_name(name: &str, ver: u16) -> Option<Badge> {
181        use BadgeKind as K;
182        let lower = name.to_ascii_lowercase();
183        let kind = match lower.as_str() {
184            "broadcaster" | "owner" => K::Broadcaster,
185            "moderator" => K::Moderator,
186            "vip" => K::Vip,
187            "subscriber" => K::Subscriber,
188            "founder" => K::Founder,
189            "staff" | "admin" => K::Staff,
190            "global_mod" => K::GlobalMod,
191            "verified" | "partner" => K::Verified,
192            "og" => K::Og,
193            "member" => K::Member,
194            "premium" => K::Premium,
195            "turbo" => K::Turbo,
196            "bits" | "bits-leader" | "anonymous-cheerer" => K::Bits,
197            "sub-gifter" | "sub_gifter" | "sub-gift-leader" => K::Gifter,
198            "predictions" => K::Predictions,
199            "hype-train" => K::Hype,
200            "artist" | "artist-badge" => K::Artist,
201            other => K::Other(other.chars().next().filter(|c| c.is_alphanumeric())?),
202        };
203        Some(Badge { kind, ver })
204    }
205
206    /// single-cell glyph — badges must never widen a line by more than one
207    /// column each.
208    pub fn glyph(self) -> char {
209        use BadgeKind as K;
210        match self.kind {
211            K::Broadcaster => 'B',
212            K::Moderator => 'M',
213            K::Vip => 'V',
214            K::Subscriber => 'S',
215            K::Founder => 'F',
216            K::Staff => 'A',
217            K::GlobalMod => 'G',
218            K::Verified => '✓',
219            K::Og => 'O',
220            K::Member => 'm',
221            K::Premium => 'P',
222            K::Turbo => 'T',
223            K::Bits => '◆',
224            K::Gifter => '✦',
225            K::Predictions => '%',
226            K::Hype => 'H',
227            K::Artist => 'a',
228            K::Other(c) => c.to_ascii_uppercase(),
229        }
230    }
231}
232
233/// what kind of non-chat event a [`Note`] marks. one vocabulary across
234/// platforms — a kick gift bomb and a twitch submysterygift are both `Gift`.
235#[derive(Clone, Copy, PartialEq, Eq, Debug)]
236pub enum NoteKind {
237    /// new subscription or resub (paid, prime, membership).
238    Sub,
239    /// gifted subs — single or bomb.
240    Gift,
241    /// money thrown at the stream: bits, kicks, superchats, superstickers.
242    Cheer,
243    /// raid — incoming or outgoing.
244    Raid,
245    /// channel point redemption.
246    Redeem,
247    /// stream went live.
248    Live,
249    /// stream went offline.
250    Offline,
251    /// game/category or title change.
252    Category,
253    /// announcement or generic system notice.
254    Notice,
255    /// heat spike / hype train — chat is going off.
256    Spike,
257    /// moderation: ban / timeout / chat clear.
258    Mod,
259    /// a streamer announcement, tinted by the color they picked.
260    Announce(Tint),
261    /// watch streak shared in chat.
262    Streak,
263    /// viewer milestone.
264    Milestone,
265    /// charity donation.
266    Charity,
267    /// a mod's anniversary (twitch modiversary).
268    ModAnniv,
269    /// a first-time chatter's welcome (ritual / new_chatter).
270    Ritual,
271    /// room mode change: slow, emote-only, followers-only, sub-only.
272    Mode,
273}
274
275/// an announcement's color, mapped onto the ansi palette: twitch's PRIMARY is
276/// the plain one, ORANGE reads as yellow, PURPLE as magenta.
277#[derive(Clone, Copy, PartialEq, Eq, Debug)]
278pub enum Tint {
279    Plain,
280    Blue,
281    Green,
282    Yellow,
283    Magenta,
284}
285
286impl Tint {
287    /// twitch's `msg-param-color` → tint; anything unknown stays plain.
288    pub fn from_name(s: &str) -> Tint {
289        match s.to_ascii_uppercase().as_str() {
290            "BLUE" => Tint::Blue,
291            "GREEN" => Tint::Green,
292            "ORANGE" => Tint::Yellow,
293            "PURPLE" => Tint::Magenta,
294            _ => Tint::Plain,
295        }
296    }
297}
298
299/// per-line extras that change how a chat line looks, grouped so a new face
300/// only has to carry one field.
301#[derive(Clone, Debug, Default, PartialEq)]
302pub struct Extra {
303    /// the text of the message being replied to (sanitized, may be empty).
304    pub reply_text: String,
305    /// `/me` — rendered in the user's color, italic.
306    pub action: bool,
307    /// the user's first message in this channel.
308    pub first: bool,
309    /// redeemed "highlight my message" — rendered reverse-video.
310    pub highlight: bool,
311    /// wall-clock seconds when the line landed (0 = unknown) — for `HH:MM`.
312    pub ts: u64,
313}
314
315/// a non-chat event shown inline in the chat flow. `what` is the human
316/// headline ("resubscribed for 14 months", "raiding with 500 viewers") — the
317/// message's `user` is the actor and its `text` carries any attached user
318/// message (a resub message, a redemption input), which renders like chat.
319#[derive(Clone, Debug, PartialEq)]
320pub struct Note {
321    pub kind: NoteKind,
322    pub what: String,
323}
324
325/// one chat line. `heat` is the channel's heat snapshotted at arrival, so a
326/// message sent during a spike stays warm in scrollback. `color` is the user's
327/// chat color (hex) if the platform sent one.
328#[derive(Clone, Debug)]
329pub struct Message {
330    /// where this line came from — a merged tab interleaves platforms, and
331    /// each line keeps its origin for display.
332    pub platform: Platform,
333    pub user: String,
334    pub text: String,
335    pub color: Option<String>,
336    pub badges: Vec<Badge>,
337    /// username this message replies to, when the platform sent one.
338    pub reply_to: Option<String>,
339    /// the platform's message id, when the relay sent one.
340    pub id: Option<String>,
341    /// set when a mod removed this line: the marker ("deleted", "timed out
342    /// 10m"…). the line stays in scrollback, struck through.
343    pub gone: Option<String>,
344    /// set when this line is an event (sub, raid, redemption, live…) rather
345    /// than plain chat — it renders as an inline notice.
346    pub note: Option<Note>,
347    /// the sender's heatsync paint-space id (twitch user id, `kick_<id>`,
348    /// `yt_<UC id>`), when the relay sent one — keys name paints and colors.
349    pub uid: Option<String>,
350    pub extra: Extra,
351    /// this line's own emote refs (see [`emote::segments_with`]), built once
352    /// when the line lands; None for lines that carry none (history, system).
353    pub emotes: Option<std::sync::Arc<emote::EmoteSet>>,
354    /// leading labels (mention origin, feed `[OP]`, `[whisper]`…); empty on
355    /// ordinary chat.
356    pub tags: Vec<Tag>,
357    pub heat: f64,
358}
359
360/// a channel's pinned message — one line in a bar above chat.
361#[derive(Clone, Debug, PartialEq, Eq)]
362pub struct Pin {
363    pub id: String,
364    pub user: String,
365    pub text: String,
366}
367
368impl Message {
369    /// the per-message ref set, empty when the line carries none.
370    pub fn refs(&self) -> &emote::EmoteSet {
371        self.emotes.as_deref().unwrap_or(emote::EmoteSet::none())
372    }
373}
374
375/// the platform message ids a tab has already drawn, so one message is one row
376/// however many times it is handed over: the server replays a joined room's
377/// backlog on every (re)subscribe, history overlaps the live tail, a second tab
378/// joins the same room. keyed by platform + channel + id, never by text (two
379/// people saying the same words are two lines). bounded: the oldest id is
380/// forgotten first, and it reaches back past what the ring itself still holds.
381#[derive(Clone, Debug, Default)]
382struct Shown {
383    ids: HashSet<(Platform, String)>,
384    order: VecDeque<(Platform, String)>,
385    cap: usize,
386}
387
388impl Shown {
389    /// true the first time `(platform, channel, id)` is seen, false after.
390    fn first(&mut self, platform: Platform, channel: &str, id: &str) -> bool {
391        let key = (
392            platform,
393            format!("{}\u{1}{id}", channel.to_ascii_lowercase()),
394        );
395        if self.ids.contains(&key) {
396            return false;
397        }
398        if self.order.len() >= self.cap {
399            if let Some(old) = self.order.pop_front() {
400                self.ids.remove(&old);
401            }
402        }
403        self.ids.insert(key.clone());
404        self.order.push_back(key);
405        true
406    }
407}
408
409/// live state for a single channel. bounded ring buffer — a raid can flood
410/// forever and we never grow past `cap` (mele is 8GB; resource-conscious).
411/// heat is a decaying counter driven by message arrivals.
412#[derive(Clone, Debug)]
413pub struct Channel {
414    /// a real channel, or one of the virtual tabs (see [`TabKind`]).
415    pub kind: TabKind,
416    pub name: String,
417    /// primary source — sends target it, and it leads the tab label.
418    pub platform: Platform,
419    /// further sources merged into this tab (a `+`-joined channel). their
420    /// lines interleave into the one ring in arrival order.
421    pub extra: Vec<(Platform, String)>,
422    /// the unsaved "stream i'm looking at" tab: one slot, never persisted or
423    /// shared, promoted to a saved tab by keeping it.
424    pub temp: bool,
425    pub heat: f64,
426    /// wall-clock ms of the last heat update — decay is computed against it.
427    pub last_ms: u64,
428    pub messages: VecDeque<Message>,
429    /// live lines recorded since the tab was last on screen.
430    unread: u32,
431    /// one of them pinged the user.
432    pinged: bool,
433    /// one of them was a stream event.
434    evented: bool,
435    /// the channel's pinned message, while it has one.
436    pin: Option<Pin>,
437    /// id of the pin the user dismissed — it stays hidden until a new one lands.
438    pin_dismissed: Option<String>,
439    /// lines ever appended at the live end — a monotonic clock for "how many
440    /// arrived since", unaffected by the ring evicting old ones.
441    pub seq: u64,
442    cap: usize,
443    /// ids of the lines already drawn here — see [`Shown`].
444    shown: Shown,
445}
446
447/// what a tab has waiting, for its color: nothing, chat, a stream event, or a ping.
448#[derive(Clone, Copy, PartialEq, Eq, Debug)]
449pub enum Unread {
450    None,
451    Chat,
452    /// a stream event (went live / offline / game switch) landed unseen.
453    Event,
454    Ping,
455}
456
457impl Channel {
458    pub fn new(name: &str, platform: Platform, cap: usize) -> Channel {
459        Channel {
460            kind: TabKind::Channel,
461            name: name.to_string(),
462            platform,
463            extra: Vec::new(),
464            temp: false,
465            heat: 0.0,
466            last_ms: 0,
467            messages: VecDeque::with_capacity(cap),
468            unread: 0,
469            pinged: false,
470            evented: false,
471            pin: None,
472            pin_dismissed: None,
473            seq: 0,
474            cap,
475            shown: Shown {
476                cap: cap.saturating_mul(4),
477                ..Shown::default()
478            },
479        }
480    }
481
482    /// a virtual tab. it joins nothing: `subs()` is empty, so no line ever
483    /// matches it and no part / emote / history call is made for it.
484    pub fn special(kind: TabKind, cap: usize) -> Channel {
485        Channel {
486            kind,
487            ..Channel::new(kind.name(), Platform::Twitch, cap)
488        }
489    }
490
491    /// append a line to a virtual tab: counts as unread, but keeps the row's
492    /// own heat (a feed row's heat is the server's, not ours).
493    pub fn deliver(&mut self, msg: Message) {
494        if self.messages.len() == self.cap {
495            self.messages.pop_front();
496        }
497        self.messages.push_back(msg);
498        self.seq += 1;
499        self.unread = self.unread.saturating_add(1);
500    }
501
502    /// the tab's unread state.
503    pub fn unread(&self) -> Unread {
504        if self.pinged {
505            Unread::Ping
506        } else if self.evented {
507            Unread::Event
508        } else if self.unread > 0 {
509            Unread::Chat
510        } else {
511            Unread::None
512        }
513    }
514
515    /// flag the most recent line as a ping (caller knows who "me" is).
516    pub fn ping(&mut self) {
517        self.pinged = true;
518    }
519
520    /// a pin arrived (`Some`) or was cleared (`None`).
521    pub fn set_pin(&mut self, pin: Option<Pin>) {
522        self.pin = pin;
523    }
524
525    /// the pin to show, unless the user dismissed this very one.
526    pub fn visible_pin(&self) -> Option<&Pin> {
527        self.pin
528            .as_ref()
529            .filter(|p| self.pin_dismissed.as_deref() != Some(p.id.as_str()))
530    }
531
532    /// hide the current pin (a newer one will show again). false = none shown.
533    pub fn dismiss_pin(&mut self) -> bool {
534        let id = self.visible_pin().map(|p| p.id.clone());
535        let some = id.is_some();
536        if some {
537            self.pin_dismissed = id;
538        }
539        some
540    }
541
542    /// flag the tab as holding an unseen stream event.
543    pub fn flag_event(&mut self) {
544        self.evented = true;
545    }
546
547    /// add a client-side line (a send rejection, a connection notice): keeps the
548    /// cap, but touches neither heat nor unread — it is not chat.
549    pub fn system(&mut self, msg: Message) {
550        if self.messages.len() == self.cap {
551            self.messages.pop_front();
552        }
553        self.messages.push_back(msg);
554        self.seq += 1;
555    }
556
557    /// slot a line in by its own time (`extra.ts`, seconds) — a replayed or
558    /// back-dated event, not news: no heat, no unread, no ping. it lands after
559    /// the last line that is not newer than it. false = it would sit older than
560    /// everything in a full ring, so it is dropped.
561    pub fn insert_by_ts(&mut self, msg: Message) -> bool {
562        let at = self
563            .messages
564            .iter()
565            .rposition(|m| m.extra.ts <= msg.extra.ts)
566            .map_or(0, |i| i + 1);
567        if self.messages.len() == self.cap {
568            if at == 0 {
569                return false;
570            }
571            self.messages.pop_front();
572            self.messages.insert(at - 1, msg);
573        } else {
574            self.messages.insert(at, msg);
575        }
576        true
577    }
578
579    /// a mod removed lines: mark every matching chat line (events are never
580    /// struck). returns how many were marked. the caller has already routed
581    /// `d` to this tab; platform is checked per line (merged tabs).
582    pub fn apply(&mut self, d: &proto::Delete) -> usize {
583        let mut n = 0;
584        for m in self.messages.iter_mut() {
585            if m.platform != d.platform || m.note.is_some() || m.gone.is_some() {
586                continue;
587            }
588            let hit = match &d.target {
589                proto::Target::Msg(id) => m.id.as_deref() == Some(id.as_str()),
590                proto::Target::User(u) => !m.user.is_empty() && m.user.eq_ignore_ascii_case(u),
591                proto::Target::All => true,
592            };
593            if hit {
594                m.gone = Some(d.label.clone());
595                n += 1;
596            }
597        }
598        n
599    }
600
601    /// the tab is on screen — everything it holds is now seen.
602    pub fn mark_seen(&mut self) {
603        self.unread = 0;
604        self.pinged = false;
605        self.evented = false;
606    }
607
608    /// every source feeding this tab, primary first.
609    pub fn subs(&self) -> impl Iterator<Item = (Platform, &str)> {
610        // a virtual tab has no sources — it must never match a line or be parted.
611        let own = (self.kind == TabKind::Channel).then_some((self.platform, self.name.as_str()));
612        own.into_iter()
613            .chain(self.extra.iter().map(|(p, n)| (*p, n.as_str())))
614    }
615
616    /// does a line for (platform, channel) belong to this tab?
617    pub fn matches(&self, platform: Platform, name: &str) -> bool {
618        self.subs()
619            .any(|(p, n)| p == platform && n.eq_ignore_ascii_case(name))
620    }
621
622    /// is this a merged (multi-source) tab?
623    pub fn merged(&self) -> bool {
624        !self.extra.is_empty()
625    }
626
627    /// advance heat decay to `now_ms` without adding anything (idle cooling).
628    pub fn cool(&mut self, now_ms: u64) {
629        if self.last_ms == 0 {
630            self.last_ms = now_ms;
631            return;
632        }
633        let dt = now_ms.saturating_sub(self.last_ms) as f64;
634        self.heat = heat::decay(self.heat, dt);
635        self.last_ms = now_ms;
636    }
637
638    /// is this the first time this tab sees the platform message `id` of
639    /// `channel`? false = it was already drawn: drop the copy. a line with no id
640    /// can't be told apart from a repeat, so it always counts as new.
641    pub fn first_sight(&mut self, platform: Platform, channel: &str, id: Option<&str>) -> bool {
642        match id.filter(|i| !i.is_empty()) {
643            Some(id) => self.shown.first(platform, channel, id),
644            None => true,
645        }
646    }
647
648    /// seed scrollback with archived history of `channel`: prepended
649    /// oldest-outward, skipping any message id already drawn (the live feed may
650    /// overlap the archive's tail), heat untouched — history is cold by
651    /// definition. `msgs` is chronological (oldest first); the cap still bounds
652    /// the buffer.
653    pub fn backfill(&mut self, channel: &str, msgs: Vec<Message>) {
654        for m in msgs.into_iter().rev() {
655            if self.messages.len() == self.cap {
656                break;
657            }
658            if !self.first_sight(m.platform, channel, m.id.as_deref()) {
659                continue;
660            }
661            self.messages.push_front(m);
662        }
663    }
664
665    /// record a message: decay to now, add one increment, snapshot heat onto
666    /// the line, then store it (evicting the oldest past the cap).
667    pub fn record(&mut self, mut msg: Message, now_ms: u64) {
668        self.cool(now_ms);
669        self.heat += heat::INCREMENT;
670        msg.heat = self.heat;
671        if self.messages.len() == self.cap {
672            self.messages.pop_front();
673        }
674        self.messages.push_back(msg);
675        self.seq += 1;
676        self.unread = self.unread.saturating_add(1);
677    }
678}
679
680#[cfg(test)]
681mod channel_tests {
682    use super::*;
683
684    #[test]
685    fn unread_goes_none_chat_ping_and_clears_when_seen() {
686        let mut c = Channel::new("x", Platform::Twitch, 8);
687        assert_eq!(c.unread(), Unread::None);
688        c.record(msg("a", "hi"), 1);
689        assert_eq!(c.unread(), Unread::Chat);
690        c.flag_event();
691        assert_eq!(c.unread(), Unread::Event);
692        c.record(msg("a", "again"), 1);
693        assert_eq!(
694            c.unread(),
695            Unread::Event,
696            "chat does not downgrade an event"
697        );
698        c.ping();
699        assert_eq!(c.unread(), Unread::Ping);
700        c.record(msg("b", "yo"), 2);
701        assert_eq!(
702            c.unread(),
703            Unread::Ping,
704            "a later plain line does not downgrade a ping"
705        );
706        c.mark_seen();
707        assert_eq!(c.unread(), Unread::None);
708    }
709
710    #[test]
711    fn a_dismissed_pin_stays_hidden_until_a_new_one_lands() {
712        let pin = |id: &str| Pin {
713            id: id.into(),
714            user: "u".into(),
715            text: "t".into(),
716        };
717        let mut c = Channel::new("x", Platform::Twitch, 8);
718        assert!(!c.dismiss_pin(), "nothing to dismiss");
719        c.set_pin(Some(pin("a")));
720        assert_eq!(c.visible_pin().map(|p| p.id.as_str()), Some("a"));
721        assert!(c.dismiss_pin());
722        assert!(c.visible_pin().is_none());
723        c.set_pin(Some(pin("a")));
724        assert!(c.visible_pin().is_none(), "same pin re-sent stays hidden");
725        c.set_pin(Some(pin("b")));
726        assert!(c.visible_pin().is_some());
727        c.set_pin(None);
728        assert!(c.visible_pin().is_none());
729    }
730
731    fn gone_after(c: &Channel) -> Vec<Option<String>> {
732        c.messages.iter().map(|m| m.gone.clone()).collect()
733    }
734
735    fn del(target: proto::Target, label: &str) -> proto::Delete {
736        proto::Delete {
737            platform: Platform::Twitch,
738            channel: "x".into(),
739            target,
740            label: label.into(),
741        }
742    }
743
744    #[test]
745    fn delete_by_id_marks_only_that_line() {
746        let mut c = Channel::new("x", Platform::Twitch, 8);
747        for (i, t) in ["a", "b", "c"].iter().enumerate() {
748            let mut m = msg("u", t);
749            m.id = Some(format!("id{i}"));
750            c.record(m, 1);
751        }
752        assert_eq!(
753            c.apply(&del(proto::Target::Msg("id1".into()), "deleted")),
754            1
755        );
756        assert_eq!(gone_after(&c), vec![None, Some("deleted".into()), None]);
757        assert_eq!(
758            c.apply(&del(proto::Target::Msg("id1".into()), "deleted")),
759            0,
760            "already marked"
761        );
762    }
763
764    #[test]
765    fn timeout_marks_all_of_a_users_chat_lines_not_events() {
766        let mut c = Channel::new("x", Platform::Twitch, 8);
767        c.record(msg("Troll", "a"), 1);
768        c.record(msg("other", "b"), 1);
769        let mut ev = msg("troll", "");
770        ev.note = Some(Note {
771            kind: NoteKind::Sub,
772            what: "subbed".into(),
773        });
774        c.record(ev, 1);
775        c.record(msg("TROLL", "c"), 1);
776        assert_eq!(
777            c.apply(&del(proto::Target::User("troll".into()), "timed out 10m")),
778            2
779        );
780        let g = gone_after(&c);
781        assert_eq!(g[0].as_deref(), Some("timed out 10m"));
782        assert_eq!(g[1], None);
783        assert_eq!(g[2], None, "events stay");
784        assert_eq!(g[3].as_deref(), Some("timed out 10m"));
785    }
786
787    #[test]
788    fn clear_marks_everything_on_that_platform_only() {
789        let mut c = Channel::new("x", Platform::Twitch, 8);
790        c.record(msg("a", "1"), 1);
791        let mut k = msg("b", "2");
792        k.platform = Platform::Kick;
793        c.record(k, 1);
794        c.apply(&del(proto::Target::All, "cleared"));
795        let g = gone_after(&c);
796        assert_eq!(g[0].as_deref(), Some("cleared"));
797        assert_eq!(g[1], None);
798    }
799
800    #[test]
801    fn seq_counts_appends_through_eviction() {
802        let mut c = Channel::new("x", Platform::Twitch, 2);
803        for i in 0..5 {
804            c.record(msg("u", &i.to_string()), 1);
805        }
806        assert_eq!((c.seq, c.messages.len()), (5, 2));
807    }
808
809    #[test]
810    fn backfill_is_not_unread() {
811        let mut c = Channel::new("x", Platform::Twitch, 8);
812        c.backfill("x", vec![msg("a", "old")]);
813        assert_eq!(c.unread(), Unread::None);
814    }
815
816    fn msg(user: &str, text: &str) -> Message {
817        Message {
818            platform: Platform::Twitch,
819            user: user.into(),
820            text: text.into(),
821            color: None,
822            badges: Vec::new(),
823            id: None,
824            gone: None,
825            reply_to: None,
826            note: None,
827            extra: Default::default(),
828            emotes: None,
829            tags: Vec::new(),
830            heat: 0.0,
831            uid: None,
832        }
833    }
834
835    #[test]
836    fn insert_by_ts_lands_in_time_order_without_heat_or_unread() {
837        let at = |t: u64, text: &str| {
838            let mut m = msg("u", text);
839            m.extra.ts = t;
840            m
841        };
842        let mut ch = Channel::new("c", Platform::Twitch, 3);
843        ch.system(at(10, "a"));
844        ch.system(at(30, "c"));
845        assert!(ch.insert_by_ts(at(20, "b")));
846        let texts = |ch: &Channel| {
847            ch.messages
848                .iter()
849                .map(|m| m.text.clone())
850                .collect::<Vec<_>>()
851        };
852        assert_eq!(texts(&ch), ["a", "b", "c"]);
853        assert_eq!((ch.heat, ch.unread()), (0.0, Unread::None));
854        // full ring: older than everything is dropped, anything else evicts the oldest
855        assert!(!ch.insert_by_ts(at(5, "old")));
856        assert!(ch.insert_by_ts(at(25, "mid")));
857        assert_eq!(texts(&ch), ["b", "mid", "c"]);
858        assert!(ch.insert_by_ts(at(40, "new")));
859        assert_eq!(texts(&ch), ["mid", "c", "new"]);
860    }
861
862    fn with_id(user: &str, text: &str, id: &str) -> Message {
863        Message {
864            id: Some(id.into()),
865            ..msg(user, text)
866        }
867    }
868
869    /// what the feed does with a line: drop a seen id, else record it.
870    fn live(ch: &mut Channel, m: Message) -> bool {
871        let fresh = ch.first_sight(m.platform, "c", m.id.as_deref());
872        if fresh {
873            ch.record(m, 1);
874        }
875        fresh
876    }
877
878    #[test]
879    fn backfill_prepends_in_order_and_dedupes_against_live_by_id() {
880        let mut ch = Channel::new("c", Platform::Twitch, 10);
881        assert!(live(&mut ch, with_id("live", "already here", "L")));
882        ch.backfill(
883            "c",
884            vec![
885                with_id("a", "one", "1"),
886                with_id("live", "already here", "L"), // overlap with the live tail
887                with_id("b", "two", "2"),
888            ],
889        );
890        let got: Vec<&str> = ch.messages.iter().map(|m| m.text.as_str()).collect();
891        assert_eq!(got, vec!["one", "two", "already here"]);
892    }
893
894    #[test]
895    fn backfill_never_dedupes_by_text() {
896        // the same words from the same person, twice, are two lines; only an id
897        // says "that is the one already drawn". rows with no id are never dropped.
898        let mut ch = Channel::new("c", Platform::Twitch, 10);
899        assert!(live(&mut ch, with_id("amy", "LUL", "L1")));
900        ch.backfill(
901            "c",
902            vec![
903                with_id("amy", "LUL", "H1"),
904                with_id("amy", "LUL", "H2"),
905                msg("amy", "LUL"),
906                msg("amy", "LUL"),
907            ],
908        );
909        assert_eq!(ch.messages.len(), 5);
910    }
911
912    #[test]
913    fn history_is_remembered_so_the_live_copy_is_dropped() {
914        let mut ch = Channel::new("c", Platform::Twitch, 10);
915        ch.backfill("c", vec![with_id("a", "one", "1")]);
916        assert!(
917            !live(&mut ch, with_id("a", "one", "1")),
918            "history, then live"
919        );
920        assert_eq!(ch.messages.len(), 1);
921    }
922
923    #[test]
924    fn first_sight_is_once_per_platform_channel_and_id() {
925        let mut c = Channel::new("x", Platform::Twitch, 8);
926        assert!(c.first_sight(Platform::Twitch, "x", Some("a")));
927        assert!(!c.first_sight(Platform::Twitch, "x", Some("a")), "a replay");
928        assert!(
929            !c.first_sight(Platform::Twitch, "X", Some("a")),
930            "case is not a channel"
931        );
932        assert!(
933            c.first_sight(Platform::Twitch, "y", Some("a")),
934            "shared chat: another channel's copy"
935        );
936        assert!(
937            c.first_sight(Platform::Kick, "x", Some("a")),
938            "another platform"
939        );
940        assert!(c.first_sight(Platform::Twitch, "x", Some("b")));
941        // no id (or an empty one) can't be told from a repeat: always new
942        assert!(c.first_sight(Platform::Twitch, "x", None));
943        assert!(c.first_sight(Platform::Twitch, "x", None));
944        assert!(c.first_sight(Platform::Twitch, "x", Some("")));
945        assert!(c.first_sight(Platform::Twitch, "x", Some("")));
946    }
947
948    #[test]
949    fn the_id_memory_outlives_the_ring_and_forgets_oldest_first() {
950        // a 2-line ring remembers 8 ids: a replay of a line that already scrolled
951        // out of the ring is still a repeat, and the memory itself stays bounded
952        let mut ch = Channel::new("c", Platform::Twitch, 2);
953        for i in 0..20 {
954            assert!(live(&mut ch, with_id("u", "x", &i.to_string())));
955        }
956        assert_eq!(ch.messages.len(), 2);
957        assert!(!live(&mut ch, with_id("u", "x", "19")), "in the ring");
958        assert!(
959            !live(&mut ch, with_id("u", "x", "14")),
960            "out of the ring, still remembered"
961        );
962        assert!(
963            live(&mut ch, with_id("u", "x", "0")),
964            "forgotten: the memory is bounded"
965        );
966        assert!(ch.shown.ids.len() <= 8 && ch.shown.order.len() <= 8);
967    }
968
969    #[test]
970    fn backfill_respects_the_cap_keeping_the_newest_history() {
971        let mut ch = Channel::new("c", Platform::Twitch, 3);
972        ch.record(msg("live", "now"), 1);
973        ch.backfill("c", (0..5).map(|i| msg("u", &format!("h{i}"))).collect());
974        assert_eq!(ch.messages.len(), 3);
975        let got: Vec<&str> = ch.messages.iter().map(|m| m.text.as_str()).collect();
976        // newest history survives, oldest is dropped
977        assert_eq!(got, vec!["h3", "h4", "now"]);
978    }
979
980    #[test]
981    fn merged_tab_matches_every_sub_case_insensitively() {
982        let mut ch = Channel::new("xqc", Platform::Twitch, 10);
983        ch.extra = vec![
984            (Platform::Kick, "xqc".into()),
985            (Platform::Youtube, "Vid_123".into()),
986        ];
987        assert!(ch.merged());
988        assert!(ch.matches(Platform::Twitch, "XQC"));
989        assert!(ch.matches(Platform::Kick, "xqc"));
990        assert!(ch.matches(Platform::Youtube, "vid_123"));
991        assert!(!ch.matches(Platform::Kick, "other"));
992        assert_eq!(ch.subs().count(), 3);
993        assert!(!Channel::new("xqc", Platform::Twitch, 10).merged());
994    }
995
996    #[test]
997    fn backfill_leaves_heat_alone() {
998        let mut ch = Channel::new("c", Platform::Kick, 10);
999        ch.backfill("c", vec![msg("a", "x")]);
1000        assert_eq!(ch.heat, 0.0);
1001        assert_eq!(ch.messages[0].heat, 0.0);
1002    }
1003
1004    #[test]
1005    fn special_tabs_join_nothing_and_match_nothing() {
1006        let mut c = Channel::special(TabKind::Mentions, 10);
1007        assert_eq!(c.kind, TabKind::Mentions);
1008        assert_eq!(c.subs().count(), 0);
1009        assert!(!c.matches(Platform::Twitch, "mentions")); // not even its own name
1010        let mut m = msg("bob", "hey");
1011        m.heat = 42.0;
1012        c.deliver(m);
1013        assert_eq!(c.messages[0].heat, 42.0, "row keeps its own heat");
1014        assert_eq!(c.heat, 0.0);
1015        assert_eq!(c.unread(), Unread::Chat);
1016        c.ping();
1017        assert_eq!(c.unread(), Unread::Ping);
1018    }
1019
1020    #[test]
1021    fn tab_kinds_parse_and_pin_in_order() {
1022        assert_eq!(TabKind::parse("Feed"), Some(TabKind::Feed));
1023        assert_eq!(TabKind::parse("xqc"), None);
1024        assert_eq!(TabKind::parse("channel"), None);
1025        assert!(TabKind::Mentions < TabKind::Feed && TabKind::Feed < TabKind::Whispers);
1026        assert!(!TabKind::Channel.is_special());
1027    }
1028}