Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions src/twitch/irc.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
33 changes: 30 additions & 3 deletions src/twitch/irc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}
}

Expand All @@ -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. */
Expand All @@ -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);
Expand Down Expand Up @@ -224,6 +250,7 @@ function handleMessage(event: MessageEvent<string>) {
colour: parsed.colour,
text: parsed.text,
badges: parsed.badges,
emotes: parsed.emotes.length > 0 ? parsed.emotes : undefined,
timestamp: Date.now(),
};
pushChatMessage(msg);
Expand Down
87 changes: 86 additions & 1 deletion src/widgets/chat/ChatWidget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): ChatMessage {
const msg: ChatMessage = {
Expand Down Expand Up @@ -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(<ChatWidget instanceId="chat-1" />);

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(<ChatWidget instanceId="chat-1" />);

expect(screen.getByText(/no emotes here/)).toBeTruthy();
});
});
Loading