Replaces previewable links in Twitch chat with inline image/gif/video previews
// ==UserScript==
// @name Twitch Chat Inline Link Preview
// @namespace local
// @version 0.2.1
// @description Replaces previewable links in Twitch chat with inline image/gif/video previews
// @match https://*.twitch.tv/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const PROCESSED_ATTR = 'data-inline-preview-processed';
const PREVIEW_CLASS = 'tm-inline-link-preview';
const HIDDEN_LINK_ATTR = 'data-inline-preview-hidden-link';
injectStyles();
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
.${PREVIEW_CLASS} {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.${PREVIEW_CLASS} a {
display: inline-flex;
text-decoration: none;
}
.${PREVIEW_CLASS} img,
.${PREVIEW_CLASS} video {
max-width: 220px;
max-height: 180px;
border-radius: 8px;
display: block;
object-fit: contain;
background: rgba(255,255,255,0.06);
}
.${PREVIEW_CLASS} .tm-preview-item {
display: flex;
}
a[${HIDDEN_LINK_ATTR}="1"] {
display: none !important;
}
`;
document.head.appendChild(style);
}
function isImageUrl(url) {
return /\.(png|jpe?g|gif|webp|avif)(\?.*)?$/i.test(url);
}
function isVideoUrl(url) {
return /\.(mp4|webm)(\?.*)?$/i.test(url);
}
function parse7tvUrl(url) {
try {
const u = new URL(url);
const cdnMatch = u.href.match(/cdn\.7tv\.app\/emote\/([A-Za-z0-9_-]+)\/([1-4]x)\.(webp|gif|avif|png)/i);
if (cdnMatch) {
return { type: 'image', src: u.href };
}
const m = u.pathname.match(/^\/emotes\/([A-Za-z0-9_-]+)/i);
if (m) {
const id = m[1];
return {
type: 'image',
src: `https://cdn.7tv.app/emote/${id}/4x.webp`,
fallbackSrc: `https://cdn.7tv.app/emote/${id}/4x.avif`
};
}
return null;
} catch {
return null;
}
}
function classifyUrl(url) {
const sevenTv = parse7tvUrl(url);
if (sevenTv) return sevenTv;
if (isImageUrl(url)) return { type: 'image', src: url };
if (isVideoUrl(url)) return { type: 'video', src: url };
return null;
}
function createPreviewItem(preview, originalUrl) {
const wrapper = document.createElement('div');
wrapper.className = 'tm-preview-item';
const link = document.createElement('a');
link.href = originalUrl;
link.target = '_blank';
link.rel = 'noopener noreferrer';
if (preview.type === 'image') {
const img = document.createElement('img');
img.loading = 'lazy';
img.alt = 'preview';
img.src = preview.src;
if (preview.fallbackSrc) {
img.addEventListener('error', () => {
if (img.src !== preview.fallbackSrc) {
img.src = preview.fallbackSrc;
}
}, { once: true });
}
link.appendChild(img);
} else if (preview.type === 'video') {
const video = document.createElement('video');
video.src = preview.src;
video.muted = true;
video.loop = true;
video.autoplay = true;
video.playsInline = true;
video.controls = false;
link.appendChild(video);
} else {
return null;
}
wrapper.appendChild(link);
return wrapper;
}
function getChatMessageNodes(root = document) {
const selectors = [
'[data-a-target="chat-line-message"]',
'.chat-line__message',
'.paid-pinned-chat-message-content-wrapper',
];
const result = [];
for (const selector of selectors) {
root.querySelectorAll(selector).forEach(node => result.push(node));
}
return result;
}
function cleanMessageSpacing(node) {
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
child.textContent = child.textContent.replace(/\s+/g, ' ');
if (!child.textContent.trim()) {
child.remove();
}
}
}
}
function processMessageNode(node) {
if (!(node instanceof HTMLElement)) return;
if (node.getAttribute(PROCESSED_ATTR) === '1') return;
const anchors = Array.from(node.querySelectorAll('a[href]'));
if (!anchors.length) {
node.setAttribute(PROCESSED_ATTR, '1');
return;
}
const previews = [];
const used = new Set();
for (const a of anchors) {
const url = a.href;
if (!url || used.has(url)) continue;
const preview = classifyUrl(url);
if (!preview) continue;
previews.push({ preview, url, anchor: a });
used.add(url);
if (previews.length >= 2) break;
}
if (previews.length > 0) {
for (const item of previews) {
item.anchor.setAttribute(HIDDEN_LINK_ATTR, '1');
item.anchor.setAttribute('aria-hidden', 'true');
}
cleanMessageSpacing(node);
const container = document.createElement('div');
container.className = PREVIEW_CLASS;
for (const item of previews) {
const el = createPreviewItem(item.preview, item.url);
if (el) container.appendChild(el);
}
if (container.childNodes.length > 0) {
node.appendChild(container);
}
}
node.setAttribute(PROCESSED_ATTR, '1');
}
function scanAll() {
for (const node of getChatMessageNodes()) {
processMessageNode(node);
}
}
function startObserver() {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const added of mutation.addedNodes) {
if (!(added instanceof HTMLElement)) continue;
if (added.matches?.('[data-a-target="chat-line-message"], .chat-line__message')) {
processMessageNode(added);
} else {
const nested = getChatMessageNodes(added);
for (const node of nested) {
processMessageNode(node);
}
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
function boot() {
scanAll();
startObserver();
}
boot();
})();