From 888334bc428978b8d85c0330a43fa7cf379b5af1 Mon Sep 17 00:00:00 2001 From: Fabian Zimber Date: Wed, 14 Oct 2026 10:01:56 +0000 Subject: [PATCH 1/2] feat(chat): render native Twitch GIFs and optional GIF links --- README.md | 13 +++- docs/ARCHITECTURE.md | 16 +++- docs/DEPLOYMENT.md | 5 +- src/components/chat/Chat.module.css | 13 ++++ src/components/chat/ChatLine.tsx | 27 ++++++- src/components/chat/GifView.tsx | 71 ++++++++++++++++++ src/components/legal/services.ts | 32 ++++++++ src/components/setup/Configurator.tsx | 14 +++- src/components/setup/options.ts | 2 + src/lib/chat/gifs.ts | 37 ++++++++++ src/lib/chat/session.ts | 22 ++++-- src/lib/chat/tokenize.ts | 47 ++++++++++-- src/lib/overlay/gifs.ts | 102 ++++++++++++++++++++++++++ src/lib/overlay/settings.ts | 10 +++ src/routes/datenschutz.tsx | 10 +++ src/routes/privacy.tsx | 9 +++ test/chat/irc.test.ts | 41 +++++++++++ test/chat/session.test.ts | 13 +++- test/overlay/gifs.test.ts | 95 ++++++++++++++++++++++++ test/overlay/settings.test.ts | 2 + test/setup/link.test.ts | 2 + 21 files changed, 562 insertions(+), 21 deletions(-) create mode 100644 src/components/chat/GifView.tsx create mode 100644 src/lib/chat/gifs.ts create mode 100644 src/lib/overlay/gifs.ts create mode 100644 test/overlay/gifs.test.ts diff --git a/README.md b/README.md index 7f1856e..197c892 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,9 @@ shows the default look. Unknown and invalid values fall back to the default. | `custom` | A font name of up to 40 characters: letters, digits, space, hyphen, underscore, dot | none | A font installed on the computer that shows the overlay. It comes before `font`, which stays the fallback. Nothing is downloaded for it | | `nl` | `1`, `0` | `0` | The message starts on a new line below the name | | `names` | `1`, `0` | `1` | Show user names | -| `homies` | `1`, `0` | `0` | Show Chatterino Homies badges, if `badges` is on. The only option that makes the overlay contact further hosts, see [How it works](#how-it-works) | +| `homies` | `1`, `0` | `0` | Show Chatterino Homies badges, if `badges` is on, see [How it works](#how-it-works) | +| `gifs` | `1`, `0` | `1` | Show native Twitch GIFs, loaded from GIPHY | +| `giflinks` | `1`, `0` | `0` | Show direct GIPHY and Tenor GIF image links in messages | Two more parameters are not part of the look. `demo=1` shows sample messages instead of a channel's chat and opens no chat connection, though its real badges and emotes load from the @@ -119,6 +121,15 @@ cache. arrive through Petal's data gateway, which fetches them from the services and caches them. The services do not see your IP address for these requests. - **Images and live updates** are loaded by the browser from the services themselves. +- **GIFs** from Twitch's GIF keyboard appear inline by default; `gifs=0` keeps their text instead. + `giflinks=1` additionally embeds direct HTTPS `.gif` links from `media.giphy.com`, + `media0.giphy.com` through `media4.giphy.com`, `i.giphy.com` and `media.tenor.com`. Share pages + stay text. GIFs fit within 320 × 240 pixels and the overlay width, keeping their aspect ratio. + Only visible GIFs load, without cookies, referrers, redirects or HTTP caching. Downloads time + out after 10 seconds and are limited to 5 MiB, 1,048,576 pixels per frame, 300 frames and + 67,108,864 total frame pixels. Invalid or unavailable images fall back to their original text. + Leaving the visible overlay, fading out, moderation and removal cancel downloads and release + image elements and blob URLs; final memory reclamation is managed by the browser. - **Chatterino Homies badges** are off by default. With `homies=1` and `badges` on, the browser loads their lists and images from `chatterinohomies.com`, `cdn.chatterinohomies.com` and `itzalex.github.io`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3b56b79..8c4fc73 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -60,6 +60,8 @@ Everything behind a running overlay besides Petal itself: | Service | Asked by the Worker; by the browser only in the fallback | Asked by the browser | Used for | | ------------------ | --------------------------------------------- | --------------------------------------- | ------------------------------------ | | Twitch | `irc-ws.chat.twitch.tv` | `static-cdn.jtvnw.net` | Chat, emote and badge images | +| GIPHY | | `media.giphy.com`, `media0.giphy.com`–`media4.giphy.com`, `i.giphy.com` | Native Twitch GIFs (`gifs=1`, default) and direct GIF links (`giflinks=1`, opt-in) | +| Tenor | | `media.tenor.com` | Direct GIF links, only with `giflinks=1` | | IVR | `api.ivr.fi` | | Twitch badge sets | | 7TV | `7tv.io` | `events.7tv.io` | Emotes, paints, badges, live updates | | BetterTTV | `api.betterttv.net` | `cdn.betterttv.net`, `sockets.betterttv.net` | Emotes, badges, username effects | @@ -81,8 +83,11 @@ every provider socket, and exposes reactive state to the Solid components: for provider data. `platform.ts` tells both whether there is a relay to ask. - `providers/` has one module per service: REST loaders, and the live sockets for 7TV (EventAPI), BTTV and FFZ (pubsub). -- `tokenize.ts` turns a message into text, mention and emote parts, and `effects.ts` reduces emote +- `tokenize.ts` turns a message into text, mention, GIF and emote parts, and `effects.ts` reduces emote modifiers to CSS. +- `gifs.ts` validates native Twitch GIF ranges and direct media URLs. `overlay/gifs.ts` bounds + GIF downloads and checks their containers; `GifView.tsx` loads only visible GIFs and releases + their image elements, requests and blob URLs when hidden or removed. - `socket.ts` is the shared `ReconnectingSocket` behind every connection: jittered exponential backoff after a drop. The chat and 7TV connections are also replaced when they go quiet. - `demo.ts` is the sample chat behind `demo=1`. @@ -151,16 +156,22 @@ a link without parameters shows the default look. Invalid values fall back to th | `nl` | `0` | The message starts on a new line below the name | | `names` | `1` | Show user names | | `homies` | `0` | Show Chatterino Homies badges | +| `gifs` | `1` | Show native Twitch GIFs from GIPHY | +| `giflinks` | `0` | Show direct GIPHY and Tenor GIF links | The filters run in the overlay. The relay passes on every line of the channel, whatever the link says. -`homies` is the one option that reaches a third party. With `homies=1` and badges on, +Some options load additional third-party images. With `homies=1` and badges on, `src/lib/chat/providers/homies.ts` loads three lists, from `chatterinohomies.com` and `itzalex.github.io`, whole and without credentials, and accepts badge images from `cdn.chatterinohomies.com` and `itzalex.github.io` only. The lists are not behind the gateway. Without the parameter no request goes to any of these hosts. +Native Twitch GIFs load from GIPHY by default; `gifs=0` keeps their original text. `giflinks=1` +also loads direct GIF image links from GIPHY and Tenor. Both use the same bounded, visible-only +loader; see the GIF limits in [README.md](../README.md#how-it-works). + Two more parameters are not part of the look. `demo=1` replaces the chat session by the sample chat, and `direct=1` switches the relay and the gateway off for that overlay. @@ -623,6 +634,7 @@ log or to Analytics Engine, and they never appear in an error message or in `/ap | Rate limiter | Counts of requests per client address and endpoint, over one minute, in the memory of Cloudflare's rate limiter | | `/api/status` | Counts per hub, the pause of a hub and the version of the deployment | | Browser, local storage | The theme choice under `theme`, once the button in the header was used. Not on overlay pages | +| Browser, GIFs | Only visible GIFs: bounded downloads with `cache: no-store`, rendered through temporary blob URLs. Hidden, faded, moderated and removed GIFs abort downloads and release those URLs and image elements. No persistent GIF cache; the browser controls final memory reclamation | The channel in a URL is the login of the streamer whose chat is shown. It identifies the stream, not a viewer. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 72fd426..08bf4ed 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -422,13 +422,16 @@ The options travel in the query string of the overlay link, so they need no stor | `nl` | `1`, `0` | `0` | The message starts on a new line below the name | | `names` | `1`, `0` | `1` | Show user names | | `homies` | `1`, `0` | `0` | Show Chatterino Homies badges | +| `gifs` | `1`, `0` | `1` | Show native Twitch GIFs from GIPHY | +| `giflinks` | `1`, `0` | `0` | Show direct GIPHY and Tenor GIF image links | `demo=1` and `direct=1` are not part of the look: the first shows sample messages and opens no chat connection, the second bypasses the relay and the gateway. What operations need to know about them: - Every overlay page is rendered by the Worker, so the whole link, options included, is in the request log. `ignore` names accounts; the privacy policy says so. -- `homies=1` is the only option that makes the overlay contact further hosts: the browser loads the badge lists from `chatterinohomies.com` and `itzalex.github.io` and the images from `cdn.chatterinohomies.com` and `itzalex.github.io`, directly and not through the gateway. It is off by default, and without it no request goes to any of them. +- With `homies=1`, the browser loads the badge lists from `chatterinohomies.com` and `itzalex.github.io` and the images from `cdn.chatterinohomies.com` and `itzalex.github.io`, directly and not through the gateway. It is off by default, and without it no request goes to any of them. +- Native GIFs load from GIPHY by default (`gifs=0` disables them). Direct GIPHY and Tenor GIF links load only with `giflinks=1`. These browser requests bypass the gateway, omit credentials and referrers, reject redirects and disable HTTP caching. Only visible GIFs load; hidden or removed GIFs cancel downloads and release their blob URLs. See [GIF hosts and limits](../README.md#how-it-works). - `custom` names a font of the streamer's computer. Nothing is downloaded for it, and a font that is not installed falls back to `font`. - An option is never removed or given another meaning once it has shipped: the links are in OBS scenes. diff --git a/src/components/chat/Chat.module.css b/src/components/chat/Chat.module.css index 15216b0..bc1f930 100644 --- a/src/components/chat/Chat.module.css +++ b/src/components/chat/Chat.module.css @@ -127,6 +127,19 @@ font-weight: 700; } +.gif { + display: inline-block; + max-width: 100%; + vertical-align: middle; +} + +.gif img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + /* The message below badges and name. As a block it starts a line of its own, and takes no line where neither badges nor a name stand before it. */ .overlay[data-newline="1"] .message { diff --git a/src/components/chat/ChatLine.tsx b/src/components/chat/ChatLine.tsx index 55eb44c..0257d40 100644 --- a/src/components/chat/ChatLine.tsx +++ b/src/components/chat/ChatLine.tsx @@ -7,6 +7,7 @@ import { textScale } from "~/lib/overlay/look"; import styles from "./Chat.module.css"; import EmoteView, { ChatImage } from "./EmoteView"; +import GifView from "./GifView"; import { useSettings } from "./settings"; import Username from "./Username"; @@ -45,9 +46,19 @@ export default function ChatLine(props: { return [...props.session.badges(props.message), ...homies]; }); const user = () => props.session.user(props.message); + const [faded, setFaded] = createSignal(false); return ( -
+
{ + if ( + event.target === event.currentTarget && + getComputedStyle(event.currentTarget).opacity === "0" + ) + setFaded(true); + }} + >
{props.message.system}
@@ -77,6 +88,20 @@ export default function ChatLine(props: { if (part.type === "mention") { return {part.text}; } + if (part.type === "gif") { + return ( + + + + ); + } return ; }} diff --git a/src/components/chat/GifView.tsx b/src/components/chat/GifView.tsx new file mode 100644 index 0000000..ad56564 --- /dev/null +++ b/src/components/chat/GifView.tsx @@ -0,0 +1,71 @@ +import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"; + +import type { GifPart } from "~/lib/chat/tokenize"; +import { loadGif } from "~/lib/overlay/gifs"; + +import styles from "./Chat.module.css"; + +/** A slot keeps its size while off screen; only visible GIFs own a download or a blob URL. */ +export default function GifView(props: { part: GifPart; faded: boolean }) { + let slot!: HTMLSpanElement; + const [visible, setVisible] = createSignal(false); + const [failed, setFailed] = createSignal(false); + const [source, setSource] = createSignal(); + const [size, setSize] = createSignal({ width: 240, height: 180 }); + + onMount(() => { + const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting)); + observer.observe(slot); + onCleanup(() => observer.disconnect()); + }); + + createEffect(() => { + if (!visible() || props.faded || failed()) return; + const controller = new AbortController(); + let objectUrl: string | undefined; + onCleanup(() => { + controller.abort(); + setSource(undefined); + if (objectUrl) URL.revokeObjectURL(objectUrl); + }); + void loadGif(props.part.url, controller.signal).then( + ({ blob, width, height }) => { + if (controller.signal.aborted) return; + setSize({ width, height }); + objectUrl = URL.createObjectURL(blob); + setSource(objectUrl); + }, + () => { + if (!controller.signal.aborted) setFailed(true); + }, + ); + }); + + return ( + + + + {(url) => ( + {props.part.text} setFailed(true)} + /> + )} + + + + ); +} diff --git a/src/components/legal/services.ts b/src/components/legal/services.ts index e6e23b8..50920fc 100644 --- a/src/components/legal/services.ts +++ b/src/components/legal/services.ts @@ -51,6 +51,38 @@ export const overlayServices: readonly OverlayService[] = [ url: "https://legal.twitch.com/legal/privacy-notice/", policy: true, }, + { + name: "GIPHY", + purpose: { + en: "native Twitch GIFs (on by default) and direct GIF links (off by default)", + de: "native Twitch-GIFs (standardmäßig an) und direkte GIF-Links (standardmäßig aus)", + }, + direct: images, + hosts: [ + "media.giphy.com", + "media0.giphy.com", + "media1.giphy.com", + "media2.giphy.com", + "media3.giphy.com", + "media4.giphy.com", + "i.giphy.com", + ], + fallbackHosts: [], + url: "https://support.giphy.com/hc/en-us/articles/360032872931-GIPHY-Privacy-Policy", + policy: true, + }, + { + name: "Tenor", + purpose: { + en: "direct GIF links, only with giflinks=1", + de: "direkte GIF-Links, nur mit giflinks=1", + }, + direct: images, + hosts: ["media.tenor.com"], + fallbackHosts: [], + url: "https://policies.google.com/privacy", + policy: true, + }, { name: "IVR", purpose: { en: "the list of Twitch badges", de: "die Liste der Twitch-Badges" }, diff --git a/src/components/setup/Configurator.tsx b/src/components/setup/Configurator.tsx index 52d3df1..bb2bbff 100644 --- a/src/components/setup/Configurator.tsx +++ b/src/components/setup/Configurator.tsx @@ -99,7 +99,7 @@ export default function Configurator(props: { setup: Setup }) { 0} - fallback="Emotes, motion, badges, filters" + fallback="Emotes, GIFs, motion, badges, filters" > {moreChanged()} changed @@ -218,6 +218,18 @@ export default function Configurator(props: { setup: Setup }) { checked={settings().homies} onChange={(value) => setup.set("homies", value)} /> + setup.set("gifs", value)} + /> + setup.set("giflinks", value)} + /> [] = [ export const MORE_OPTIONS = [ "custom", "emotes", + "gifs", + "giflinks", "animate", "fade", "newline", diff --git a/src/lib/chat/gifs.ts b/src/lib/chat/gifs.ts new file mode 100644 index 0000000..7185c55 --- /dev/null +++ b/src/lib/chat/gifs.ts @@ -0,0 +1,37 @@ +export interface GifRange { + start: number; + end: number; + url: string; +} + +/** Only direct GIF files on the providers' image hosts, never pages or arbitrary URLs. */ +export function isGifUrl(value: string, twitch = false): boolean { + if (value.length > 4096 || /[\s\\]/.test(value)) return false; + const url = URL.parse(value); + if ( + url?.protocol !== "https:" || + url.username || + url.password || + url.port || + url.hash || + !/^\/[a-zA-Z0-9/_.~-]+\.gif$/.test(url.pathname) + ) + return false; + return ( + /^(?:media[0-4]?|i)\.giphy\.com$/.test(url.hostname) || + (!twitch && url.hostname === "media.tenor.com") + ); +} + +/** Twitch IRC `gifs`: inclusive code point ranges, GIF id, and the unmodified media URL. */ +export function parseGifsTag(tag: string | undefined): GifRange[] { + return (tag ?? "").split(",").flatMap((entry) => { + const match = /^(\d+)-(\d+)\|[a-zA-Z0-9]+\|(.+)$/.exec(entry); + if (!match || !isGifUrl(match[3], true)) return []; + const start = Number(match[1]); + const end = Number(match[2]); + return Number.isSafeInteger(start) && Number.isSafeInteger(end) && end >= start + ? [{ start, end, url: match[3] }] + : []; + }); +} diff --git a/src/lib/chat/session.ts b/src/lib/chat/session.ts index 84c924a..22c85aa 100644 --- a/src/lib/chat/session.ts +++ b/src/lib/chat/session.ts @@ -2,6 +2,7 @@ import { createSignal } from "solid-js"; import { createStore } from "solid-js/store"; import { EmoteSet } from "./emote-set"; +import { type GifRange, parseGifsTag } from "./gifs"; import { type IrcStatus, TwitchIrc } from "./irc/client"; import { type BadgeRef, @@ -63,6 +64,7 @@ export interface ChatMessage { action: boolean; text: string; emoteRanges: EmoteRange[]; + gifRanges?: GifRange[]; badgeRefs: BadgeRef[]; /** USERNOTICE text such as "x subscribed with Prime." */ system?: string; @@ -623,6 +625,7 @@ export function createChatSession(channel: string, options: ChatSessionOptions = action, text, emoteRanges: parseEmotesTag(tags.emotes), + gifRanges: parseGifsTag(tags.gifs), badgeRefs: parseBadgesTag( shared ? (tags["source-badges"] ?? tags.badges) : tags.badges, ), @@ -679,13 +682,18 @@ export function createChatSession(channel: string, options: ChatSessionOptions = ...global.ffzDefaultSets.map((id) => ffzSets.get(id)), ].filter((set) => set !== undefined); - return tokenize(message.text, message.emoteRanges, (name) => { - for (const set of sets) { - const emote = set.get(name); - if (emote) return emote; - } - return undefined; - }); + return tokenize( + message.text, + message.emoteRanges, + (name) => { + for (const set of sets) { + const emote = set.get(name); + if (emote) return emote; + } + return undefined; + }, + message.gifRanges, + ); } /** Badges in display order; re-derived when badge data or the user's cosmetics change. */ diff --git a/src/lib/chat/tokenize.ts b/src/lib/chat/tokenize.ts index 5d63c87..2e30f07 100644 --- a/src/lib/chat/tokenize.ts +++ b/src/lib/chat/tokenize.ts @@ -5,6 +5,7 @@ import { FfzFlag, NO_EFFECTS, } from "./effects"; +import { type GifRange, isGifUrl } from "./gifs"; import type { EmoteRange } from "./irc/parse"; import { twitchEmote } from "./providers/twitch"; import type { Emote } from "./types"; @@ -17,9 +18,18 @@ export interface EmotePart { effects: EmoteEffects; } +export interface GifPart { + type: "gif"; + source: "twitch" | "link"; + url: string; + /** Original chat text, also used when disabled or when the image cannot be loaded. */ + text: string; +} + export type MessagePart = | { type: "text"; text: string } | { type: "mention"; text: string } + | GifPart | EmotePart; export type EmoteLookup = (name: string) => Emote | undefined; @@ -28,11 +38,12 @@ interface Atom { word: string; /** Set for Twitch emotes, which come pre-located in the IRC tags. */ emote?: Emote; + gif?: string; spaceBefore: boolean; } -/** Splits a message into space-separated words, cutting Twitch emotes out at their ranges. */ -function atomize(text: string, ranges: EmoteRange[]): Atom[] { +/** Splits a message into words, cutting native Twitch emotes and GIFs out at their ranges. */ +function atomize(text: string, ranges: EmoteRange[], gifs: GifRange[]): Atom[] { // Twitch counts code points, not UTF-16 units, so emoji before an emote shift nothing. const chars = Array.from(text); const atoms: Atom[] = []; @@ -48,11 +59,15 @@ function atomize(text: string, ranges: EmoteRange[]): Atom[] { }; let cursor = 0; - for (const range of ranges) { + for (const range of [...ranges, ...gifs].sort((a, b) => a.start - b.start)) { if (range.start < cursor || range.end < range.start || range.end >= chars.length) continue; pushWords(chars.slice(cursor, range.start).join("")); const name = chars.slice(range.start, range.end + 1).join(""); - atoms.push({ word: name, emote: twitchEmote(range.id, name), spaceBefore: space }); + atoms.push({ + word: name, + ...("url" in range ? { gif: range.url } : { emote: twitchEmote(range.id, name) }), + spaceBefore: space, + }); space = false; cursor = range.end + 1; } @@ -61,13 +76,18 @@ function atomize(text: string, ranges: EmoteRange[]): Atom[] { } /** - * Turns a chat message into text, mention and emote parts. Third-party emotes are matched per + * Turns a chat message into text, mention, GIF and emote parts. Third-party emotes are matched per * word through `lookup`, which decides provider priority. On top of that: * - zero-width emotes (7TV, BTTV) stack onto the preceding emote; * - FFZ modifier emotes (`ffzX`, `ffzHyper`, ...) apply their flags to the preceding emote; * - BTTV prefix modifiers (`w! h! Kappa`) apply to the next emote, or stay text without one. */ -export function tokenize(text: string, ranges: EmoteRange[], lookup: EmoteLookup): MessagePart[] { +export function tokenize( + text: string, + ranges: EmoteRange[], + lookup: EmoteLookup, + gifs: GifRange[] = [], +): MessagePart[] { const parts: MessagePart[] = []; let base: EmotePart | undefined; let prefixes: Atom[] = []; @@ -95,7 +115,20 @@ export function tokenize(text: string, ranges: EmoteRange[], lookup: EmoteLookup if (!previous.text) parts.splice(index - 1, 1); }; - for (const atom of atomize(text, ranges)) { + for (const atom of atomize(text, ranges, gifs)) { + const gif = atom.gif ?? (!atom.emote && isGifUrl(atom.word) ? atom.word : undefined); + if (gif) { + flushPrefixes(); + pushSpace(atom); + parts.push({ + type: "gif", + source: atom.gif ? "twitch" : "link", + url: gif, + text: atom.word, + }); + base = undefined; + continue; + } const emote = atom.emote ?? lookup(atom.word); if (emote?.bttvModifier) { diff --git a/src/lib/overlay/gifs.ts b/src/lib/overlay/gifs.ts new file mode 100644 index 0000000..8e54ff3 --- /dev/null +++ b/src/lib/overlay/gifs.ts @@ -0,0 +1,102 @@ +import { isGifUrl } from "../chat/gifs"; + +const MAX_BYTES = 5 * 1024 * 1024; +const MAX_PIXELS = 1024 * 1024; +const MAX_FRAME_PIXELS = 64 * 1024 * 1024; +const LOAD_TIMEOUT = 10_000; + +/** Check the container before handing it to the browser's image decoder. No frames are decoded. */ +function gifSize(bytes: Uint8Array): { width: number; height: number } { + const invalid = () => new Error("Unsupported GIF"); + const word = (at: number) => bytes[at] | (bytes[at + 1] << 8); + const signature = String.fromCharCode(...bytes.subarray(0, 6)); + if (bytes.length < 13 || (signature !== "GIF89a" && signature !== "GIF87a")) throw invalid(); + const width = word(6); + const height = word(8); + if (!width || !height || width * height > MAX_PIXELS) throw invalid(); + const tableSize = (flags: number) => (flags & 128 ? 3 * 2 ** ((flags & 7) + 1) : 0); + let pos = 13 + tableSize(bytes[10]); + let frames = 0; + const skipBlocks = () => { + while (pos < bytes.length) { + const length = bytes[pos++]; + if (!length) return; + pos += length; + } + throw invalid(); + }; + while (pos < bytes.length) { + const block = bytes[pos++]; + if (block === 0x3b && frames > 0) return { width, height }; + if (block === 0x21) { + pos++; // Extension label; all extensions contain length-prefixed sub-blocks. + skipBlocks(); + } else if (block === 0x2c) { + if ( + pos + 9 >= bytes.length || + !word(pos + 4) || + !word(pos + 6) || + word(pos) + word(pos + 4) > width || + word(pos + 2) + word(pos + 6) > height || + ++frames > 300 || + frames * width * height > MAX_FRAME_PIXELS + ) + throw invalid(); + pos += 9 + tableSize(bytes[pos + 8]); + const codeSize = bytes[pos++]; + if (codeSize < 2 || codeSize > 8) throw invalid(); + skipBlocks(); + } else { + throw invalid(); + } + } + throw invalid(); +} + +/** Bounded, cancellable downloads with no HTTP cache, cookies, referrer or redirects. */ +export async function loadGif(url: string, signal: AbortSignal) { + if (!isGifUrl(url)) throw new Error("Unsupported GIF URL"); + const timeout = new AbortController(); + const timer = setTimeout(() => timeout.abort(), LOAD_TIMEOUT); + const aborted = AbortSignal.any([signal, timeout.signal]); + try { + const response = await fetch(url, { + signal: aborted, + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + redirect: "error", + headers: { Accept: "image/gif" }, + }); + if ( + !response.ok || + response.headers.get("content-type")?.split(";")[0].trim() !== "image/gif" || + Number(response.headers.get("content-length")) > MAX_BYTES || + !response.body + ) { + await response.body?.cancel(); + throw new Error("Unsupported GIF response"); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + aborted.throwIfAborted(); + if (done) break; + length += value.byteLength; + if (length > MAX_BYTES) throw new Error("GIF is too large"); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + const blob = new Blob(chunks, { type: "image/gif" }); + const size = gifSize(new Uint8Array(await blob.arrayBuffer())); + aborted.throwIfAborted(); + return { blob, ...size }; + } finally { + clearTimeout(timer); + } +} diff --git a/src/lib/overlay/settings.ts b/src/lib/overlay/settings.ts index 9a78c67..f537242 100644 --- a/src/lib/overlay/settings.ts +++ b/src/lib/overlay/settings.ts @@ -46,6 +46,10 @@ export interface OverlaySettings { names: boolean; /** Show Homies badges, which the overlay has to ask a third party for. */ homies: boolean; + /** Native GIFs selected with Twitch's GIF keyboard. */ + gifs: boolean; + /** Direct GIPHY and Tenor GIF links in ordinary message text. */ + giflinks: boolean; } // Fonts the site serves itself (declared in brand.css and app.css) and system font stacks: the @@ -79,6 +83,8 @@ export const DEFAULT_SETTINGS: OverlaySettings = { newline: false, names: true, homies: false, + gifs: true, + giflinks: false, }; export const MAX_FADE = 600; @@ -145,6 +151,8 @@ export function parseSettings(params: URLSearchParams): OverlaySettings { newline: flag(params.get("nl"), DEFAULT_SETTINGS.newline), names: flag(params.get("names"), DEFAULT_SETTINGS.names), homies: flag(params.get("homies"), DEFAULT_SETTINGS.homies), + gifs: flag(params.get("gifs"), DEFAULT_SETTINGS.gifs), + giflinks: flag(params.get("giflinks"), DEFAULT_SETTINGS.giflinks), }; } @@ -180,6 +188,8 @@ export function settingsQuery(settings: OverlaySettings): string { add("newline", settings.newline, "nl"); add("names", settings.names); add("homies", settings.homies); + add("gifs", settings.gifs); + add("giflinks", settings.giflinks); return pairs.length > 0 ? `?${pairs.join("&")}` : ""; } diff --git a/src/routes/datenschutz.tsx b/src/routes/datenschutz.tsx index d2cde88..bb8322b 100644 --- a/src/routes/datenschutz.tsx +++ b/src/routes/datenschutz.tsx @@ -131,6 +131,16 @@ export default function Datenschutz() { Live-Aktualisierungen und im Ersatzfall die meisten auch den Twitch-Kanal.

+

+ Native Twitch-GIFs werden direkt von GIPHY geladen, solange der Overlay-Link nicht + gifs=0 enthält. Direkte GIPHY- und Tenor-GIF-Links in Nachrichten werden nur mit + giflinks=1 geladen. GIFs werden nur im sichtbaren Bereich angefordert, ohne Cookies + oder Referrer und mit deaktiviertem Browser-Cache. Der Anbieter erhält deine + IP-Adresse und Browserangaben; native GIF-URLs behalten die von Twitch gelieferten + Parameter. Verlässt ein GIF den sichtbaren Bereich, wird ausgeblendet oder entfernt, + bricht Petal seinen Download ab und gibt das Bild und seine temporäre Blob-URL frei. + Relay und Gateway speichern keine GIF-Dateien. +

Badges von Chatterino Homies sind ausgeschaltet, solange die Adresse des Overlays nicht homies=1 enthält. Nur dann lädt dein Browser die Listen dieser Badges von diff --git a/src/routes/privacy.tsx b/src/routes/privacy.tsx index ca16fcf..b3caf29 100644 --- a/src/routes/privacy.tsx +++ b/src/routes/privacy.tsx @@ -121,6 +121,15 @@ export default function Privacy() { details, and for live updates and in the fallback most of them the Twitch channel.

+

+ Native Twitch GIFs load directly from GIPHY unless the overlay link contains gifs=0. + Direct GIPHY and Tenor GIF links in messages load only with giflinks=1. GIFs are + requested only while visible, without cookies or a referrer, and with browser + caching disabled. The provider receives your IP address and browser details; native + GIF URLs retain the parameters supplied by Twitch. When a GIF leaves the visible + overlay, fades out or is removed, Petal cancels its download and releases its image + and temporary blob URL. GIF files are not stored by the relay or gateway. +

Badges of Chatterino Homies are switched off unless the address of the overlay contains homies=1. Only then does your browser load the lists of these badges from diff --git a/test/chat/irc.test.ts b/test/chat/irc.test.ts index ac0c38d..28af4ec 100644 --- a/test/chat/irc.test.ts +++ b/test/chat/irc.test.ts @@ -3,6 +3,7 @@ import { it } from "node:test"; import { FfzFlag } from "../../src/lib/chat/effects"; import { EmoteSet } from "../../src/lib/chat/emote-set"; +import { isGifUrl, parseGifsTag } from "../../src/lib/chat/gifs"; import { parseEmotesTag, parseIrcLine } from "../../src/lib/chat/irc/parse"; import { type MessagePart, tokenize } from "../../src/lib/chat/tokenize"; import type { Emote } from "../../src/lib/chat/types"; @@ -52,6 +53,7 @@ const show = (list: MessagePart[]) => .map((part) => { if (part.type === "text") return part.text; if (part.type === "mention") return `<${part.text}>`; + if (part.type === "gif") return `[gif:${part.text}]`; const overlays = part.overlays.map((o) => `+${o.name}`).join(""); return `[${part.emote.provider}:${part.emote.name}${overlays}]`; }) @@ -77,6 +79,45 @@ it("cuts messages into text, mentions and emotes", () => { } }); +it("keeps Twitch GIF ranges and direct links separate, with unsafe URLs left as text", () => { + const url = "https://media4.giphy.com/media/joSNxeswxuc74Juo8X/giphy.gif?cid=twitch&ct=g"; + const text = "👋 [GIF] Kappa"; + const gifs = parseGifsTag(`2-6|joSNxeswxuc74Juo8X|${url}`); + const result = tokenize(text, parseEmotesTag("25:8-12"), () => undefined, gifs); + assert.equal(show(result), "👋 [gif:[GIF]] [twitch:Kappa]"); + assert.deepEqual(result[1], { type: "gif", source: "twitch", url, text: "[GIF]" }); + assert.deepEqual(parts(url), [{ type: "gif", source: "link", url, text: url }]); + const tenor = "https://media.tenor.com/example/hello.gif"; + assert.equal(isGifUrl(tenor), true); + assert.equal(isGifUrl(tenor, true), false); + for (const unsafe of [ + "http://media.giphy.com/media/a/giphy.gif", + "https://media.giphy.com.evil.example/media/a/giphy.gif", + "https://media.giphy.com@evil.example/a.gif", + "https://evil.example/a.gif", + "https://media.giphy.com:8080/a.gif", + "https://media.giphy.com/a.svg", + "https://giphy.com/gifs/hello-123", + "https://tenor.com/view/hello-123", + "data:image/gif;base64,AAAA", + ]) { + assert.equal(isGifUrl(unsafe), false, unsafe); + assert.deepEqual(parts(unsafe), [{ type: "text", text: unsafe }]); + assert.deepEqual(parseGifsTag(`2-6|id|${unsafe}`), []); + } + assert.deepEqual(parseGifsTag(`6-2|id|${url},NaN-6|id|${url}`), []); + assert.equal( + show( + tokenize(text, [], () => undefined, [ + ...gifs, + { start: 4, end: 6, url }, + { start: 8, end: 99, url }, + ]), + ), + "👋 [gif:[GIF]] Kappa", + ); +}); + it("applies BTTV modifiers to the next emote and FFZ modifiers to the previous one", () => { const [, bttv] = parts("look w! h! Clap"); const [ffz] = parts("Clap ffzW ffzHyper"); diff --git a/test/chat/session.test.ts b/test/chat/session.test.ts index 92a3121..d1258e4 100644 --- a/test/chat/session.test.ts +++ b/test/chat/session.test.ts @@ -28,7 +28,7 @@ function openSession(t: TestContext) { chat()?.receive([JOIN_ECHO, ...replay].join("\r\n")); }; const ids = () => session.state.messages.map((message) => message.id); - return { chat, join, ids }; + return { chat, join, ids, session }; } it("renders a line once, however often it arrives", (t) => { @@ -52,3 +52,14 @@ it("removes what moderators removed and does not bring it back", (t) => { chat()?.receive("@target-user-id=1 :tmi.twitch.tv CLEARCHAT #forsen :u1"); assert.deepEqual(ids(), []); }); + +it("passes Twitch GIF tags through replay and preserves the complete media URL", (t) => { + const { chat, join, session } = openSession(t); + const url = "https://media1.giphy.com/media/p3hZ9PbhVBO9gM6LoE/giphy.gif?cid=twitch&ct=g"; + join(line("[GIF]", "1", `;petal-replay=1;gifs=0-4|p3hZ9PbhVBO9gM6LoE|${url}`)); + assert.deepEqual(session.parts(session.state.messages[0]), [ + { type: "gif", source: "twitch", url, text: "[GIF]" }, + ]); + chat()?.receive("@target-msg-id=[GIF] :tmi.twitch.tv CLEARMSG #forsen :[GIF]"); + assert.equal(session.state.messages.length, 0); +}); diff --git a/test/overlay/gifs.test.ts b/test/overlay/gifs.test.ts new file mode 100644 index 0000000..6fcc4a8 --- /dev/null +++ b/test/overlay/gifs.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; + +import { loadGif } from "../../src/lib/overlay/gifs"; + +const URL = "https://media1.giphy.com/media/p3hZ9PbhVBO9gM6LoE/giphy.gif?cid=twitch&ct=g"; +const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", "base64"); +const response = (bytes: Uint8Array = GIF, headers: Record = {}) => + new Response(bytes, { headers: { "content-type": "image/gif", ...headers } }); + +it("loads only bounded GIFs, without cache, credentials, referrer or redirects", async (t) => { + const fetch = t.mock.method(globalThis, "fetch", async (url: string, options: RequestInit) => { + assert.equal(url, URL); + assert.equal(options.cache, "no-store"); + assert.equal(options.credentials, "omit"); + assert.equal(options.referrerPolicy, "no-referrer"); + assert.equal(options.redirect, "error"); + return response(); + }); + await assert.rejects( + loadGif("https://untrusted.example/image.gif", new AbortController().signal), + ); + assert.equal(fetch.mock.callCount(), 0); + const result = await loadGif(URL, new AbortController().signal); + assert.equal(result.width, 1); + assert.equal(result.height, 1); + assert.deepEqual(Buffer.from(await result.blob.arrayBuffer()), GIF); +}); + +it("rejects wrong types, truncated containers, oversized frames and excessive frame counts", async (t) => { + const huge = Buffer.from(GIF); + huge.writeUInt16LE(65535, 6); + huge.writeUInt16LE(65535, 8); + const frames = Buffer.concat([ + GIF.subarray(0, 19), + ...Array(301).fill(GIF.subarray(19, -1)), + Buffer.from([0x3b]), + ]); + const manyPixels = Buffer.from(frames); + manyPixels.writeUInt16LE(1024, 6); + manyPixels.writeUInt16LE(1024, 8); + for (const answer of [ + response(GIF, { "content-type": "image/svg+xml" }), + response(GIF, { "content-length": "5242881" }), + response(Buffer.from("")), + response(GIF.subarray(0, -1)), + response(huge), + response(frames), + response(manyPixels), + ]) { + t.mock.method(globalThis, "fetch", async () => answer); + await assert.rejects(loadGif(URL, new AbortController().signal)); + } +}); + +it("cancels streams over the byte limit even without a Content-Length", async (t) => { + let cancelled = false; + t.mock.method( + globalThis, + "fetch", + async () => + new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(1024 * 1024)); + }, + cancel() { + cancelled = true; + }, + }), + { headers: { "content-type": "image/gif" } }, + ), + ); + await assert.rejects(loadGif(URL, new AbortController().signal), /too large/); + assert.equal(cancelled, true); +}); + +it("forwards cancellation and times out a stalled fetch", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + t.mock.method( + globalThis, + "fetch", + async (_url: string, options: RequestInit) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener("abort", () => reject(options.signal?.reason)); + }), + ); + const controller = new AbortController(); + const cancelled = assert.rejects(loadGif(URL, controller.signal)); + controller.abort(); + await cancelled; + const timedOut = assert.rejects(loadGif(URL, new AbortController().signal)); + t.mock.timers.tick(10_000); + await timedOut; +}); diff --git a/test/overlay/settings.test.ts b/test/overlay/settings.test.ts index 84dc8e7..20cfd5c 100644 --- a/test/overlay/settings.test.ts +++ b/test/overlay/settings.test.ts @@ -33,6 +33,8 @@ describe("parseSettings", () => { }, ], ["size=3&size=2", { size: 3 }], + ["gifs=0&giflinks=true", { gifs: false, giflinks: true }], + ["gifs=yes&giflinks=2", {}], ["ignore=b_bot&ignore=@A_Bot,,bad-name", { ignore: ["a_bot", "b_bot"] }], ["custom=Comic+Sans+MS", { custom: "Comic Sans MS" }], ["size=4&emotes=1.5&stroke=-1&shadow=9&font=comic&fade=601", {}], diff --git a/test/setup/link.test.ts b/test/setup/link.test.ts index 59eb216..8ebd43b 100644 --- a/test/setup/link.test.ts +++ b/test/setup/link.test.ts @@ -34,6 +34,8 @@ describe("overlayUrl", () => { { newline: true }, { names: false }, { homies: true }, + { gifs: false }, + { giflinks: true }, ]; // Each change on its own, then all of them in one link. const rows = changes.map((change): [Partial, number] => [change, 1]); From fa4877175fb9bd264a09265727e271a4edb8f800 Mon Sep 17 00:00:00 2001 From: Fabian Zimber Date: Wed, 14 Oct 2026 13:15:16 +0000 Subject: [PATCH 2/2] fix(chat): preserve GIF slots and preview GIF options --- README.md | 4 +-- docs/DEPLOYMENT.md | 2 +- src/components/chat/ChatLine.tsx | 6 +++- src/lib/chat/demo.ts | 31 +++++++++++++++----- src/lib/chat/session.ts | 3 +- src/lib/chat/tokenize.ts | 18 ++++++++---- src/routes/datenschutz.tsx | 6 ++-- src/routes/privacy.tsx | 12 ++++---- test/chat/session.test.ts | 49 ++++++++++++++++++++++++++++++++ 9 files changed, 106 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 197c892..39525dc 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,8 @@ shows the default look. Unknown and invalid values fall back to the default. | `giflinks` | `1`, `0` | `0` | Show direct GIPHY and Tenor GIF image links in messages | Two more parameters are not part of the look. `demo=1` shows sample messages instead of a -channel's chat and opens no chat connection, though its real badges and emotes load from the -providers' image hosts; the preview on the start page uses it. `direct=1` makes +channel's chat and opens no chat connection, though its real badges, emotes and enabled GIFs load +from the providers' image hosts; the preview on the start page uses it. `direct=1` makes the overlay connect to Twitch and the emote services itself instead of using Petal's relay and cache. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 08bf4ed..b4db4bb 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -26,7 +26,7 @@ If the relay or the gateway cannot be reached, the overlay connects to Twitch an There is one page for visitors: the start page `/`. Its hero ends in the channel field, and the section `#setup` below it holds the look options, the live preview and the personal link, which reflects the channel and the look. The steps for OBS, the way from nothing to chat on screen in short, the features and the questions follow. `/setup` redirects to `/#setup` with status 302. -The overlay lives at `/chat/` and nowhere else; every path that is not listed above answers with the 404 page. Links to the overlay are in OBS scenes, so the path and the options of a link keep their meaning. The preview on the start page is the overlay itself in a frame, with `demo=1`: sample messages and no chat connection; only the images of its real badges and emotes load from the providers' hosts. +The overlay lives at `/chat/` and nowhere else; every path that is not listed above answers with the 404 page. Links to the overlay are in OBS scenes, so the path and the options of a link keep their meaning. The preview on the start page is the overlay itself in a frame, with `demo=1`: sample messages and no chat connection; its real badges, emotes and enabled GIFs load from the providers' hosts. Native and direct-link GIPHY examples let visitors preview both GIF options independently. ### What streamers need diff --git a/src/components/chat/ChatLine.tsx b/src/components/chat/ChatLine.tsx index 0257d40..dee4cfb 100644 --- a/src/components/chat/ChatLine.tsx +++ b/src/components/chat/ChatLine.tsx @@ -2,6 +2,7 @@ import { createMemo, createSignal, For, Show } from "solid-js"; import type { HomiesBadge } from "~/lib/chat/providers/homies"; import type { ChatMessage, ChatSession } from "~/lib/chat/session"; +import type { MessagePart } from "~/lib/chat/tokenize"; import type { Badge } from "~/lib/chat/types"; import { textScale } from "~/lib/overlay/look"; @@ -39,7 +40,10 @@ export default function ChatLine(props: { homies?: readonly HomiesBadge[]; }) { const settings = useSettings(); - const parts = createMemo(() => props.session.parts(props.message)); + const parts = createMemo( + (previous) => props.session.parts(props.message, previous), + [], + ); const badges = createMemo((): (Badge | HomiesBadge)[] => { if (!settings().badges) return []; const homies = settings().homies ? (props.homies ?? []) : []; diff --git a/src/lib/chat/demo.ts b/src/lib/chat/demo.ts index b6b65f3..97e0759 100644 --- a/src/lib/chat/demo.ts +++ b/src/lib/chat/demo.ts @@ -1,6 +1,7 @@ import { createSignal } from "solid-js"; import { createStore } from "solid-js/store"; +import type { GifRange } from "./gifs"; import type { HomiesBadge, HomiesBadges } from "./providers/homies"; import type { Paint } from "./providers/seventv/paint"; import type { ChatMessage, ChatSession } from "./session"; @@ -22,7 +23,7 @@ import { // The real images, from the hosts an overlay page loads them from anyway (see the privacy // policy, "Chat overlay"): Twitch's global badges and emotes, emotes of the 7TV and BetterTTV -// global sets, and a shared Homies badge. Nothing is invented and nothing ships with the site. +// global sets, a shared Homies badge, and a GIPHY GIF. Nothing ships with the site. const TWITCH_BADGES = "https://static-cdn.jtvnw.net/badges/v1"; const SEVENTV_CDN = "https://cdn.7tv.app/emote"; @@ -128,10 +129,13 @@ const lurker: Chatter = { name: "hazel_newt", color: "#ff4500", badges: [] }; interface Sample { chatter: Chatter; text: string; + gifRanges?: GifRange[]; action?: boolean; system?: string; } +const GIF_URL = "https://media1.giphy.com/media/p3hZ9PbhVBO9gM6LoE/giphy.gif?cid=twitch&ct=g"; + const SAMPLES: Sample[] = [ { chatter: mod, text: "Welcome in, everyone HeyGuys" }, { chatter: newcomer, text: "first time here, this chat looks so clean" }, @@ -140,8 +144,14 @@ const SAMPLES: Sample[] = [ chatter: regular, text: "I have been watching for two hours, I still do not understand the rules of this game, and I am having a great time", }, - { chatter: painted, text: "<3 <3 <3" }, { chatter: bot, text: "Enjoying the stream? Follow the channel, so you never miss one." }, + { + chatter: painted, + text: "[GIF]", + gifRanges: [{ start: 0, end: 4, url: GIF_URL }], + }, + { chatter: vip, text: GIF_URL }, + { chatter: painted, text: "<3 <3 <3" }, { chatter: lurker, text: "!uptime" }, { chatter: subscriber, text: "that was so close PogChamp" }, { chatter: vip, text: "clip it, someone clip it LUL SourPls" }, @@ -172,8 +182,8 @@ const PAUSES = [1200, 1800, 1000, 1600, 2000, 1400]; const MAX_MESSAGES = 100; /** - * A chat session without a chat: it repeats a dozen invented messages and opens no connection. - * Only its badge and emote images are loaded, from the hosts of the real ones. The start page + * A chat session without a chat: it repeats invented messages and opens no connection. + * Its badge, emote and enabled GIF images load from the same hosts as real chat. The start page * previews the overlay with it, and a streamer can arrange a scene with it while the channel is * offline. */ @@ -218,7 +228,7 @@ export function createDemoSession(options: DemoOptions = {}): ChatSession { } function addNext() { - const { chatter, text, action, system } = SAMPLES[count % SAMPLES.length]; + const { chatter, text, gifRanges, action, system } = SAMPLES[count % SAMPLES.length]; const login = chatter.name.toLowerCase(); const message: ChatMessage = { id: `demo-${count}`, @@ -230,6 +240,7 @@ export function createDemoSession(options: DemoOptions = {}): ChatSession { action: action ?? false, text, emoteRanges: [], + gifRanges, badgeRefs: chatter.badges.map((ref) => { const [set, version] = ref.split("/") as [string, string]; return { set, version }; @@ -248,8 +259,14 @@ export function createDemoSession(options: DemoOptions = {}): ChatSession { return { state, - parts: (message: ChatMessage): MessagePart[] => - tokenize(message.text, message.emoteRanges, (name) => EMOTES.get(name)), + parts: (message: ChatMessage, previous: readonly MessagePart[] = []): MessagePart[] => + tokenize( + message.text, + message.emoteRanges, + (name) => EMOTES.get(name), + message.gifRanges, + previous, + ), badges: (message: ChatMessage): Badge[] => message.badgeRefs.flatMap((ref) => badge(ref.set, ref.version) ?? []), paint: (message: ChatMessage) => CHATTERS.get(message.userId)?.paint, diff --git a/src/lib/chat/session.ts b/src/lib/chat/session.ts index 22c85aa..4c706c2 100644 --- a/src/lib/chat/session.ts +++ b/src/lib/chat/session.ts @@ -664,7 +664,7 @@ export function createChatSession(channel: string, options: ChatSessionOptions = }); /** Message parts; re-derived when any emote source the message draws from changes. */ - function parts(message: ChatMessage): MessagePart[] { + function parts(message: ChatMessage, previous: readonly MessagePart[] = []): MessagePart[] { // Read purely to subscribe: global/room set changes, and this user's personal emotes. emotesVersion(); void state.users[message.userId]?.personalEmotes; @@ -693,6 +693,7 @@ export function createChatSession(channel: string, options: ChatSessionOptions = return undefined; }, message.gifRanges, + previous, ); } diff --git a/src/lib/chat/tokenize.ts b/src/lib/chat/tokenize.ts index 2e30f07..69704e7 100644 --- a/src/lib/chat/tokenize.ts +++ b/src/lib/chat/tokenize.ts @@ -87,8 +87,13 @@ export function tokenize( ranges: EmoteRange[], lookup: EmoteLookup, gifs: GifRange[] = [], + previous: readonly MessagePart[] = [], ): MessagePart[] { const parts: MessagePart[] = []; + // Emote updates can split text into more parts. Match GIFs by occurrence, not part index, + // so Solid's keyed list retains their downloads, dimensions and animation state. + const previousGifs = previous.filter((part) => part.type === "gif"); + let gifIndex = 0; let base: EmotePart | undefined; let prefixes: Atom[] = []; @@ -120,12 +125,13 @@ export function tokenize( if (gif) { flushPrefixes(); pushSpace(atom); - parts.push({ - type: "gif", - source: atom.gif ? "twitch" : "link", - url: gif, - text: atom.word, - }); + const source = atom.gif ? "twitch" : "link"; + const old = previousGifs[gifIndex++]; + parts.push( + old?.source === source && old.url === gif && old.text === atom.word + ? old + : { type: "gif", source, url: gif, text: atom.word }, + ); base = undefined; continue; } diff --git a/src/routes/datenschutz.tsx b/src/routes/datenschutz.tsx index bb8322b..2fb7637 100644 --- a/src/routes/datenschutz.tsx +++ b/src/routes/datenschutz.tsx @@ -80,8 +80,10 @@ export default function Datenschutz() { Beispielnachrichten und öffnet keine Chat-Verbindung. Ihre Badges und Emotes sind die echten, deshalb lädt dein Browser deren Bilder von Twitch, 7TV und BetterTTV und, wenn Homies-Badges eingeschaltet sind, von den Homies-Hosts, wie bei jeder - Overlay-Seite (siehe „Chat-Overlay“). Sobald du einen Kanal eingegeben hast, enthält - ihre Adresse auch diesen Kanal, und die Vorschau fragt unser Gateway nach den + Overlay-Seite (siehe „Chat-Overlay“). Sie enthält außerdem ein natives GIF-Beispiel + und einen direkten GIF-Link von GIPHY; diese werden nur geladen, wenn die jeweilige + GIF-Option eingeschaltet ist. Sobald du einen Kanal eingegeben hast, enthält ihre + Adresse auch diesen Kanal, und die Vorschau fragt unser Gateway nach den Twitch-Badges des Kanals, damit der Beispiel-Chat seine Abo-Badges trägt. Der Abruf der Vorschau wird wie der jeder Overlay-Seite protokolliert (siehe „Hosting“). Abgesehen von dieser Vorschau lädt die Startseite nichts von Dritten. diff --git a/src/routes/privacy.tsx b/src/routes/privacy.tsx index b3caf29..3d458ab 100644 --- a/src/routes/privacy.tsx +++ b/src/routes/privacy.tsx @@ -73,11 +73,13 @@ export default function Privacy() { loaded from our Worker: it shows sample messages and opens no chat connection. Its badges and emotes are the real ones, so your browser loads their images from Twitch, 7TV and BetterTTV and, with Homies badges switched on, from the Homies hosts, as it - would for any overlay page (see "Chat overlay"). Once you have typed a channel, its - address carries that channel as well, and the preview asks our gateway for the - channel's Twitch badges, so the sample chat wears its subscriber badges. The request - for the preview is logged like that of any overlay page (see "Hosting"). Apart from - that preview, the start page loads nothing from third parties. + would for any overlay page (see "Chat overlay"). It also includes a native GIF + example and a direct GIF link from GIPHY; these load only when their respective GIF + option is enabled. Once you have typed a channel, its address carries that channel + as well, and the preview asks our gateway for the channel's Twitch badges, so the + sample chat wears its subscriber badges. The request for the preview is logged like + that of any overlay page (see "Hosting"). Apart from that preview, the start page + loads nothing from third parties.

Theme

diff --git a/test/chat/session.test.ts b/test/chat/session.test.ts index d1258e4..2e83ca0 100644 --- a/test/chat/session.test.ts +++ b/test/chat/session.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { it, type TestContext } from "node:test"; +import { createDemoSession } from "../../src/lib/chat/demo"; import { createChatSession } from "../../src/lib/chat/session"; import { FakeWebSocket, useFakeWebSocket, useLocation } from "../helpers"; @@ -63,3 +64,51 @@ it("passes Twitch GIF tags through replay and preserves the complete media URL", chat()?.receive("@target-msg-id=[GIF] :tmi.twitch.tv CLEARMSG #forsen :[GIF]"); assert.equal(session.state.messages.length, 0); }); + +it("keeps native and repeated link GIF slots alive when a channel emote arrives", (t) => { + const { join, session } = openSession(t); + const url = "https://media1.giphy.com/media/p3hZ9PbhVBO9gM6LoE/giphy.gif?cid=twitch&ct=g"; + join( + `@id=m1;user-id=1;room-id=42;gifs=0-4|id|${url} ` + + `:u1!u1@u1.tmi.twitch.tv PRIVMSG #forsen :[GIF] Clap ${url} ${url}`, + ); + const message = session.state.messages[0]; + const before = session.parts(message); + const gifs = before.filter((part) => part.type === "gif"); + assert.deepEqual( + gifs.map((part) => part.source), + ["twitch", "link", "link"], + ); + assert.notEqual(gifs[1], gifs[2]); + + const bttv = FakeWebSocket.instances.find((ws) => ws.url.includes("sockets.betterttv.net")); + assert.ok(bttv); + bttv.receive( + JSON.stringify({ + name: "emote_create", + data: { channel: "twitch:42", emote: { id: "clap", code: "Clap" } }, + }), + ); + const after = session.parts(message, before); + assert.ok(after.some((part) => part.type === "emote" && part.emote.name === "Clap")); + const afterGifs = after.filter((part) => part.type === "gif"); + assert.equal(afterGifs.length, gifs.length); + afterGifs.forEach((part, index) => { + assert.equal(part, gifs[index]); + }); +}); + +it("includes both GIF options in the initial setup preview", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const demo = createDemoSession(); + t.after(() => demo.dispose()); + const gifs = demo.state.messages + .flatMap((message) => demo.parts(message)) + .filter((part) => part.type === "gif"); + assert.deepEqual( + gifs.map((part) => part.source), + ["twitch", "link"], + ); + assert.equal(gifs[0].text, "[GIF]"); + assert.equal(gifs[1].text, gifs[1].url); +});