diff --git a/src/twitch/irc.test.ts b/src/twitch/irc.test.ts new file mode 100644 index 0000000..c124aae --- /dev/null +++ b/src/twitch/irc.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from "vitest"; +import { parsePRIVMSG, parseColourTag, defaultColourForUsername, parseEmotes } from "./irc"; + +describe("parsePRIVMSG", () => { + it("extracts username colour from IRC tags", () => { + const raw = + "@badge-info=;badges=broadcaster/1;color=#FF4500;display-name=TestUser;emotes=;flags=;id=abc123;mod=0;room-id=12345;subscriber=0;tmi-sent-ts=1234567890;turbo=0;user-id=67890;user-type= :testuser!testuser@testuser.tmi.twitch.tv PRIVMSG #channel :Hello world"; + + const result = parsePRIVMSG(raw); + + expect(result).not.toBeNull(); + expect(result!.colour).toBe("#FF4500"); + expect(result!.username).toBe("TestUser"); + expect(result!.text).toBe("Hello world"); + }); + + it("assigns a deterministic default colour when no colour tag is present", () => { + const raw = + "@badge-info=;badges=;color=;display-name=NoColourUser;emotes=;id=abc;mod=0;room-id=123;subscriber=0;tmi-sent-ts=123;user-id=456;user-type= :nocolouruser!nocolouruser@nocolouruser.tmi.twitch.tv PRIVMSG #channel :Hi"; + + const result = parsePRIVMSG(raw); + + expect(result).not.toBeNull(); + expect(result!.colour).toBe(defaultColourForUsername("NoColourUser")); + expect(result!.colour).not.toBe("#FFFFFF"); + }); + + it("assigns a deterministic default colour when colour tag value is empty", () => { + const raw = + "@color=;display-name=User :user!user@user.tmi.twitch.tv PRIVMSG #ch :test"; + + const result = parsePRIVMSG(raw); + + expect(result).not.toBeNull(); + expect(result!.colour).toBe(defaultColourForUsername("User")); + }); + + it("extracts badges from IRC tags", () => { + const raw = + "@badges=broadcaster/1,subscriber/12;color=#9ACD32;display-name=Streamer :streamer!streamer@streamer.tmi.twitch.tv PRIVMSG #channel :Hey chat"; + + const result = parsePRIVMSG(raw); + + expect(result).not.toBeNull(); + expect(result!.badges).toEqual([ + { setId: "broadcaster", versionId: "1" }, + { setId: "subscriber", versionId: "12" }, + ]); + }); + + it("falls back to nick prefix when display-name is missing", () => { + const raw = + "@color=#1E90FF :someuser!someuser@someuser.tmi.twitch.tv PRIVMSG #channel :message"; + + const result = parsePRIVMSG(raw); + + expect(result).not.toBeNull(); + expect(result!.username).toBe("someuser"); + expect(result!.colour).toBe("#1E90FF"); + }); + + it("returns null for non-PRIVMSG lines", () => { + const raw = ":tmi.twitch.tv 001 justinfan12345 :Welcome, GLHF!"; + + expect(parsePRIVMSG(raw)).toBeNull(); + }); + + it("returns null when message text portion is missing", () => { + const raw = + "@display-name=User :user!user@user.tmi.twitch.tv PRIVMSG #channel"; + + expect(parsePRIVMSG(raw)).toBeNull(); + }); + + it("handles messages without tags", () => { + const raw = ":user!user@user.tmi.twitch.tv PRIVMSG #channel :no tags here"; + + const result = parsePRIVMSG(raw); + + expect(result).not.toBeNull(); + expect(result!.username).toBe("user"); + expect(result!.colour).toBe(defaultColourForUsername("user")); + expect(result!.text).toBe("no tags here"); + }); + + it("preserves the full hex colour including hash", () => { + const raw = + "@color=#00FF7F;display-name=GreenUser :greenuser!greenuser@greenuser.tmi.twitch.tv PRIVMSG #channel :green message"; + + const result = parsePRIVMSG(raw); + + expect(result!.colour).toBe("#00FF7F"); + }); +}); + +describe("parseColourTag", () => { + it("extracts colour from a GLOBALUSERSTATE line", () => { + const raw = + "@badge-info=;badges=;color=#8A2BE2;display-name=TestUser;emote-sets=0;user-id=12345;user-type= :tmi.twitch.tv GLOBALUSERSTATE"; + + expect(parseColourTag(raw)).toBe("#8A2BE2"); + }); + + it("returns null when no tags are present", () => { + expect(parseColourTag(":tmi.twitch.tv GLOBALUSERSTATE")).toBeNull(); + }); + + it("returns null when colour tag has empty value", () => { + const raw = "@color=;display-name=User :tmi.twitch.tv USERSTATE #channel"; + + expect(parseColourTag(raw)).toBeNull(); + }); + + it("extracts colour from a USERSTATE line", () => { + const raw = + "@badge-info=subscriber/1;badges=subscriber/0;color=#DAA520;display-name=Sub;emote-sets=0;mod=0;subscriber=1;user-type= :tmi.twitch.tv USERSTATE #channel"; + + expect(parseColourTag(raw)).toBe("#DAA520"); + }); +}); + +describe("parseEmotes", () => { + it("handles a single emote", () => { + const result = parseEmotes("emotesv2_abc123:0-4"); + + expect(result).toEqual([{ id: "emotesv2_abc123", start: 0, end: 4 }]); + }); + + it("handles multiple different emotes", () => { + const result = parseEmotes("emotesv2_abc:0-4/emotesv2_def:6-10"); + + expect(result).toEqual([ + { id: "emotesv2_abc", start: 0, end: 4 }, + { id: "emotesv2_def", start: 6, end: 10 }, + ]); + }); + + it("handles the same emote used at multiple positions", () => { + const result = parseEmotes("emotesv2_abc:0-4,10-14"); + + expect(result).toEqual([ + { id: "emotesv2_abc", start: 0, end: 4 }, + { id: "emotesv2_abc", start: 10, end: 14 }, + ]); + }); + + it("returns empty array for empty string", () => { + expect(parseEmotes("")).toEqual([]); + }); + + it("handles numeric emote IDs", () => { + const result = parseEmotes("25:0-4"); + + expect(result).toEqual([{ id: "25", start: 0, end: 4 }]); + }); +}); + +describe("parsePRIVMSG emotes", () => { + it("extracts emotes from IRC tags", () => { + const raw = + "@color=#FF4500;display-name=User;emotes=25:0-4 :user!user@user.tmi.twitch.tv PRIVMSG #channel :Kappa test"; + + const result = parsePRIVMSG(raw); + + expect(result).not.toBeNull(); + expect(result!.emotes).toEqual([{ id: "25", start: 0, end: 4 }]); + }); + + it("returns empty emotes when emotes tag is empty", () => { + const raw = + "@color=#FF4500;display-name=User;emotes= :user!user@user.tmi.twitch.tv PRIVMSG #channel :Hello"; + + const result = parsePRIVMSG(raw); + + expect(result).not.toBeNull(); + expect(result!.emotes).toEqual([]); + }); + + it("returns empty emotes when no emotes tag is present", () => { + const raw = + "@color=#FF4500;display-name=User :user!user@user.tmi.twitch.tv PRIVMSG #channel :Hello"; + + const result = parsePRIVMSG(raw); + + expect(result).not.toBeNull(); + expect(result!.emotes).toEqual([]); + }); +}); diff --git a/src/twitch/irc.ts b/src/twitch/irc.ts index f217168..17bc44e 100644 --- a/src/twitch/irc.ts +++ b/src/twitch/irc.ts @@ -130,8 +130,31 @@ function handleChatCommand(text: string): void { } } +/** Parse the emotes tag from an IRC message into an array of emote positions. + * Format: `emotesv2_ID:start-end,start-end/emotesv2_ID2:start-end` */ +export function parseEmotes(tag: string): Array<{ id: string; start: number; end: number }> { + if (!tag) return []; + const emotes: Array<{ id: string; start: number; end: number }> = []; + for (const entry of tag.split("/")) { + const colonIdx = entry.indexOf(":"); + if (colonIdx === -1) continue; + const id = entry.slice(0, colonIdx); + const positions = entry.slice(colonIdx + 1); + for (const pos of positions.split(",")) { + const dashIdx = pos.indexOf("-"); + if (dashIdx === -1) continue; + const start = Number(pos.slice(0, dashIdx)); + const end = Number(pos.slice(dashIdx + 1)); + if (!Number.isNaN(start) && !Number.isNaN(end)) { + emotes.push({ id, start, end }); + } + } + } + return emotes; +} + /** Parse a PRIVMSG IRC line into a chat message, or null if not parseable. */ -function parsePRIVMSG(raw: string): { username: string; colour: string; text: string; badges: Array<{ setId: string; versionId: string }> } | null { +export function parsePRIVMSG(raw: string): { username: string; colour: string; text: string; badges: Array<{ setId: string; versionId: string }>; emotes: Array<{ id: string; start: number; end: number }> } | null { const tagEnd = raw.startsWith("@") ? raw.indexOf(" ") : -1; const tags = tagEnd > 0 ? raw.slice(1, tagEnd) : ""; const rest = tagEnd > 0 ? raw.slice(tagEnd + 1) : raw; @@ -148,6 +171,7 @@ function parsePRIVMSG(raw: string): { username: string; colour: string; text: st let displayName = ""; let colour = ""; let badges: Array<{ setId: string; versionId: string }> = []; + let emotesTag = ""; for (const pair of tags.split(";")) { const eq = pair.indexOf("="); if (eq === -1) continue; @@ -160,6 +184,8 @@ function parsePRIVMSG(raw: string): { username: string; colour: string; text: st const [setId, versionId] = entry.split("/"); return { setId, versionId }; }); + } else if (key === "emotes" && value) { + emotesTag = value; } } @@ -170,7 +196,7 @@ function parsePRIVMSG(raw: string): { username: string; colour: string; text: st if (!displayName) return null; - return { username: displayName, colour: colour || defaultColourForUsername(displayName), text, badges }; + return { username: displayName, colour: colour || defaultColourForUsername(displayName), text, badges, emotes: parseEmotes(emotesTag) }; } /** Parse a JOIN or PART line, returning the username or null. */ @@ -186,7 +212,7 @@ function parseJoinPart(raw: string): { username: string; type: "join" | "part" } } /** Extract the color tag value from an IRC tags string. */ -function parseColourTag(raw: string): string | null { +export function parseColourTag(raw: string): string | null { const tagEnd = raw.startsWith("@") ? raw.indexOf(" ") : -1; if (tagEnd <= 0) return null; const tags = raw.slice(1, tagEnd); @@ -224,6 +250,7 @@ function handleMessage(event: MessageEvent) { colour: parsed.colour, text: parsed.text, badges: parsed.badges, + emotes: parsed.emotes.length > 0 ? parsed.emotes : undefined, timestamp: Date.now(), }; pushChatMessage(msg); diff --git a/src/widgets/chat/ChatWidget.test.tsx b/src/widgets/chat/ChatWidget.test.tsx index 47b6e63..d866b62 100644 --- a/src/widgets/chat/ChatWidget.test.tsx +++ b/src/widgets/chat/ChatWidget.test.tsx @@ -42,7 +42,7 @@ vi.mock("../../twitch/irc", () => ({ })); // Import after mocks are set up -const { ChatWidget } = await import("./ChatWidget"); +const { ChatWidget, splitMessageFragments } = await import("./ChatWidget"); function addTestMessage(overrides: Partial = {}): ChatMessage { const msg: ChatMessage = { @@ -150,3 +150,88 @@ describe("ChatWidget colour rendering", () => { expect(screen.getByText(/great stream!/)).toBeTruthy(); }); }); + +describe("splitMessageFragments", () => { + it("returns full text when no emotes are present", () => { + const result = splitMessageFragments("Hello world", undefined); + + expect(result).toEqual([{ type: "text", text: "Hello world" }]); + }); + + it("returns full text when emotes array is empty", () => { + const result = splitMessageFragments("Hello world", []); + + expect(result).toEqual([{ type: "text", text: "Hello world" }]); + }); + + it("splits text around a single emote", () => { + const result = splitMessageFragments("Hello Kappa world", [ + { id: "25", start: 6, end: 10 }, + ]); + + expect(result).toEqual([ + { type: "text", text: "Hello " }, + { type: "emote", id: "25", name: "Kappa" }, + { type: "text", text: " world" }, + ]); + }); + + it("handles emote at the start of the message", () => { + const result = splitMessageFragments("Kappa hello", [ + { id: "25", start: 0, end: 4 }, + ]); + + expect(result).toEqual([ + { type: "emote", id: "25", name: "Kappa" }, + { type: "text", text: " hello" }, + ]); + }); + + it("handles emote at the end of the message", () => { + const result = splitMessageFragments("hello Kappa", [ + { id: "25", start: 6, end: 10 }, + ]); + + expect(result).toEqual([ + { type: "text", text: "hello " }, + { type: "emote", id: "25", name: "Kappa" }, + ]); + }); + + it("handles multiple emotes", () => { + const result = splitMessageFragments("Kappa hi PogChamp", [ + { id: "25", start: 0, end: 4 }, + { id: "305954156", start: 9, end: 17 }, + ]); + + expect(result).toEqual([ + { type: "emote", id: "25", name: "Kappa" }, + { type: "text", text: " hi " }, + { type: "emote", id: "305954156", name: "PogChamp" }, + ]); + }); +}); + +describe("ChatWidget emote rendering", () => { + it("renders emotes as images", () => { + addTestMessage({ + username: "EmoteUser", + text: "Hello Kappa world", + emotes: [{ id: "25", start: 6, end: 10 }], + }); + + render(); + + const img = screen.getByAltText("Kappa"); + expect(img.tagName).toBe("IMG"); + expect(img.getAttribute("src")).toContain("/25/default/dark/1.0"); + }); + + it("renders plain text when no emotes are present", () => { + addTestMessage({ username: "PlainUser", text: "no emotes here" }); + + render(); + + expect(screen.getByText(/no emotes here/)).toBeTruthy(); + }); +}); diff --git a/src/widgets/chat/ChatWidget.tsx b/src/widgets/chat/ChatWidget.tsx index e119ef3..c2ab8fa 100644 --- a/src/widgets/chat/ChatWidget.tsx +++ b/src/widgets/chat/ChatWidget.tsx @@ -6,6 +6,7 @@ import { useOverlayStore } from "../../stores/overlay"; import { useTwitchStore } from "../../stores/twitch"; import { sendChatMessage, defaultColourForUsername } from "../../twitch/irc"; import { messages, listeners, messageOpacity } from "./chat-state"; +import type { ChatEmote } from "./chat-state"; import { getBadgeUrl } from "../../twitch/badges"; function useChatMessages() { @@ -18,6 +19,57 @@ function useChatMessages() { } const DEFAULT_NAME_COLOUR = "#FFFFFF"; +const EMOTE_CDN = "https://static-cdn.jtvnw.net/emoticons/v2"; + +export type MessageFragment = + | { type: "text"; text: string } + | { type: "emote"; id: string; name: string }; + +/** Split message text into text and emote fragments based on parsed emote positions. */ +export function splitMessageFragments(text: string, emotes?: ChatEmote[]): MessageFragment[] { + if (!emotes || emotes.length === 0) return [{ type: "text", text }]; + + const sorted = [...emotes].sort((a, b) => a.start - b.start); + const fragments: MessageFragment[] = []; + let cursor = 0; + + for (const emote of sorted) { + if (emote.start > cursor) { + fragments.push({ type: "text", text: text.slice(cursor, emote.start) }); + } + fragments.push({ type: "emote", id: emote.id, name: text.slice(emote.start, emote.end + 1) }); + cursor = emote.end + 1; + } + + if (cursor < text.length) { + fragments.push({ type: "text", text: text.slice(cursor) }); + } + + return fragments; +} + +function MessageText({ text, emotes }: { text: string; emotes?: ChatEmote[] }) { + const fragments = splitMessageFragments(text, emotes); + + return ( + + {": "} + {fragments.map((frag, i) => + frag.type === "text" ? ( + {frag.text} + ) : ( + {frag.name} + ), + )} + + ); +} function ChatContent({ instanceId }: { instanceId: string }) { const msgs = useChatMessages(); @@ -70,7 +122,7 @@ function ChatContent({ instanceId }: { instanceId: string }) { {msg.username} - : {msg.text} + ))} diff --git a/src/widgets/chat/chat-state.ts b/src/widgets/chat/chat-state.ts index 3d40548..ad0b256 100644 --- a/src/widgets/chat/chat-state.ts +++ b/src/widgets/chat/chat-state.ts @@ -3,12 +3,19 @@ export interface ChatBadge { versionId: string; } +export interface ChatEmote { + id: string; + start: number; + end: number; +} + export interface ChatMessage { id: string; username: string; colour: string; text: string; badges?: ChatBadge[]; + emotes?: ChatEmote[]; timestamp: number; }