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
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,13 @@ 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
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.

Expand All @@ -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`.
Expand Down
16 changes: 14 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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`.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<channel>` 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/<channel>` 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

Expand Down Expand Up @@ -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.

Expand Down
13 changes: 13 additions & 0 deletions src/components/chat/Chat.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 31 additions & 2 deletions src/components/chat/ChatLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ 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";

import styles from "./Chat.module.css";
import EmoteView, { ChatImage } from "./EmoteView";
import GifView from "./GifView";
import { useSettings } from "./settings";
import Username from "./Username";

Expand Down Expand Up @@ -38,16 +40,29 @@ export default function ChatLine(props: {
homies?: readonly HomiesBadge[];
}) {
const settings = useSettings();
const parts = createMemo(() => props.session.parts(props.message));
const parts = createMemo<MessagePart[]>(
(previous) => props.session.parts(props.message, previous),
[],
);
const badges = createMemo((): (Badge | HomiesBadge)[] => {
if (!settings().badges) return [];
const homies = settings().homies ? (props.homies ?? []) : [];
return [...props.session.badges(props.message), ...homies];
});
const user = () => props.session.user(props.message);
const [faded, setFaded] = createSignal(false);

return (
<div class={styles.line}>
<div
class={styles.line}
onAnimationEnd={(event) => {
if (
event.target === event.currentTarget &&
getComputedStyle(event.currentTarget).opacity === "0"
)
setFaded(true);
}}
>
<Show when={props.message.system}>
<div class={styles.system}>{props.message.system}</div>
</Show>
Expand Down Expand Up @@ -77,6 +92,20 @@ export default function ChatLine(props: {
if (part.type === "mention") {
return <span class={styles.mention}>{part.text}</span>;
}
if (part.type === "gif") {
return (
<Show
when={
part.source === "twitch"
? settings().gifs
: settings().giflinks
}
fallback={part.text}
>
<GifView part={part} faded={faded()} />
</Show>
);
}
return <EmoteView part={part} />;
}}
</For>
Expand Down
71 changes: 71 additions & 0 deletions src/components/chat/GifView.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
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 (
<span
ref={slot}
class={failed() ? undefined : styles.gif}
style={
failed()
? undefined
: {
width: `${Math.min(320, size().width, (240 * size().width) / size().height)}px`,
"aspect-ratio": `${size().width} / ${size().height}`,
}
}
>
<Show when={!failed()} fallback={props.part.text}>
<Show when={source()}>
{(url) => (
<img
src={url()}
alt={props.part.text}
decoding="async"
onError={() => setFailed(true)}
/>
)}
</Show>
</Show>
</span>
);
}
32 changes: 32 additions & 0 deletions src/components/legal/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading
Loading